mirror of
https://github.com/shlinkio/shlink.git
synced 2026-03-01 04:33:12 +08:00
Compare commits
69 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e60d80bb16 | ||
|
|
1d4bea68af | ||
|
|
d2f9f5fd5e | ||
|
|
f13c3364eb | ||
|
|
ac04bedead | ||
|
|
67a66cefa6 | ||
|
|
43db066cb4 | ||
|
|
faec758fba | ||
|
|
ccec6e03aa | ||
|
|
3f08b38558 | ||
|
|
1ee5f64738 | ||
|
|
d22169803f | ||
|
|
57807c4360 | ||
|
|
6e1d07b0cc | ||
|
|
0c0349fa39 | ||
|
|
8d8a0f2484 | ||
|
|
8ff913aaf2 | ||
|
|
f7d54abb2b | ||
|
|
ce990c67e3 | ||
|
|
907b8453c6 | ||
|
|
8a0ba11f79 | ||
|
|
0c1ecd3caa | ||
|
|
c07c37f7bd | ||
|
|
fe652c67f4 | ||
|
|
297985cf01 | ||
|
|
10f79ec01d | ||
|
|
e87d4d61bc | ||
|
|
e58f2a384e | ||
|
|
881002634a | ||
|
|
aa80c2bb82 | ||
|
|
75cd9774b7 | ||
|
|
1a8e4cdfd7 | ||
|
|
6858dc4785 | ||
|
|
5d1d9dcac3 | ||
|
|
732bb06c62 | ||
|
|
5f00d8b732 | ||
|
|
a3ff545d43 | ||
|
|
279bd12a2d | ||
|
|
1b2a0d674f | ||
|
|
fd82de31c0 | ||
|
|
327d35fe57 | ||
|
|
e18187f04e | ||
|
|
bd2f488e2c | ||
|
|
96350c8b8f | ||
|
|
a737eed5c5 | ||
|
|
9b2ccaeb7b | ||
|
|
304979273f | ||
|
|
7add41d560 | ||
|
|
51ebe57ac8 | ||
|
|
6ff5a532ea | ||
|
|
fccd92497a | ||
|
|
452bfea088 | ||
|
|
240d2588f9 | ||
|
|
eca7800487 | ||
|
|
b9e58b9300 | ||
|
|
54918db9ef | ||
|
|
b07a603456 | ||
|
|
4fb2c64fa8 | ||
|
|
258c4102be | ||
|
|
b9c7f8e8d4 | ||
|
|
f32e7cc7c4 | ||
|
|
4ebd48b2b0 | ||
|
|
f71bd84a20 | ||
|
|
33b45eb620 | ||
|
|
1f9a912c04 | ||
|
|
45151cdde6 | ||
|
|
8ca45eb388 | ||
|
|
b7a34a6640 | ||
|
|
8ec686f4e2 |
76
CHANGELOG.md
76
CHANGELOG.md
@@ -4,6 +4,82 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org).
|
||||
|
||||
## 2.0.5 - 2020-02-09
|
||||
|
||||
#### Added
|
||||
|
||||
* [#651](https://github.com/shlinkio/shlink/issues/651) Documented how Shlink behaves when using multiple domains.
|
||||
|
||||
#### Changed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Deprecated
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Removed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Fixed
|
||||
|
||||
* [#648](https://github.com/shlinkio/shlink/issues/648) Ensured any user can write in log files, in case shlink is run by several system users.
|
||||
* [#650](https://github.com/shlinkio/shlink/issues/650) Ensured default domain is ignored when trying to create a short URL.
|
||||
|
||||
|
||||
## 2.0.4 - 2020-02-02
|
||||
|
||||
#### Added
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Changed
|
||||
|
||||
* [#577](https://github.com/shlinkio/shlink/issues/577) Wrapped params used to customize short URL lists into a DTO with implicit validation.
|
||||
|
||||
#### Deprecated
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Removed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Fixed
|
||||
|
||||
* [#620](https://github.com/shlinkio/shlink/issues/620) Ensured "controlled" errors (like validation errors and such) won't be logged with error level, preventing logs to be polluted.
|
||||
* [#637](https://github.com/shlinkio/shlink/issues/637) Fixed several work flows in which short URLs with domain are handled form the API.
|
||||
* [#644](https://github.com/shlinkio/shlink/issues/644) Fixed visits to short URL on non-default domain being linked to the URL on default domain with the same short code.
|
||||
* [#643](https://github.com/shlinkio/shlink/issues/643) Fixed searching on short URL lists not taking into consideration the domain name.
|
||||
|
||||
|
||||
## 2.0.3 - 2020-01-27
|
||||
|
||||
#### Added
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Changed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Deprecated
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Removed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Fixed
|
||||
|
||||
* [#624](https://github.com/shlinkio/shlink/issues/624) Fixed order in which headers for remote IP detection are inspected.
|
||||
* [#623](https://github.com/shlinkio/shlink/issues/623) Fixed short URLs metadata being impossible to reset.
|
||||
* [#628](https://github.com/shlinkio/shlink/issues/628) Fixed `GET /short-urls/{shortCode}` REST endpoint returning a 404 for short URLs which are not enabled.
|
||||
* [#621](https://github.com/shlinkio/shlink/issues/621) Fixed permission denied error when updating same GeoLite file version more than once.
|
||||
|
||||
|
||||
## 2.0.2 - 2020-01-12
|
||||
|
||||
#### Added
|
||||
|
||||
64
README.md
64
README.md
@@ -22,6 +22,10 @@ A PHP-based self-hosted URL shortener that can be used to serve shortened URLs u
|
||||
- [Using a docker image](#using-a-docker-image)
|
||||
- [Using shlink](#using-shlink)
|
||||
- [Shlink CLI Help](#shlink-cli-help)
|
||||
- [Multiple domains](#multiple-domains)
|
||||
- [Management](#management)
|
||||
- [Visits](#visits)
|
||||
- [Special redirects](#special-redirects)
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -280,4 +284,64 @@ Available commands:
|
||||
visit:locate Resolves visits origin locations.
|
||||
```
|
||||
|
||||
## Multiple domains
|
||||
|
||||
While in many cases you will just have one short domain and you'll want all your short URLs to be served from it, there are some cases in which you might want to have multiple short domains served from the same Shlink instance.
|
||||
|
||||
If that's the case, you need to understand how Shlink will behave when managing your short URLs or any of them is visited.
|
||||
|
||||
### Management
|
||||
|
||||
When you create a short URL it is possible to optionally pass a `domain` param. If you don't pass it, the short URL will be created for the default domain (the one provided during Shlink's installation or in the `SHORT_DOMAIN_HOST` env var when using the docker image).
|
||||
|
||||
However, if you pass it, the short URL will be "linked" to that domain.
|
||||
|
||||
> Note that, if the default domain is passed, Shlink will ignore it and will behave as if no `domain` param was provided.
|
||||
|
||||
The main benefit of being able to pass the domain is that Shlink will allow the same custom slug to be used in multiple short URLs, as long as the domain is different (like `example.com/my-compaign`, `another.com/my-compaign` and `foo.com/my-compaign`).
|
||||
|
||||
Then, each short URL will be tracked separately and you will be able to define specific tags and metadata for each one of them.
|
||||
|
||||
However, this has a side effect. When you try to interact with an existing short URL (editing tags, editing meta, resolving it or deleting it), either from the REST API or the CLI tool, you will have to provide the domain appropriately.
|
||||
|
||||
Let's imagine this situation. Shlink's default domain is `example.com`, and you have the next short URLs:
|
||||
|
||||
* `https://example.com/abc123` -> a regular short URL where no domain was provided.
|
||||
* `https://example.com/my-campaign` -> a regular short URL where no domain was provided, but it has a custom slug.
|
||||
* `https://another.com/my-campaign` -> a short URL where the `another.com` domain was provided, and it has a custom slug.
|
||||
* `https://another.com/def456` -> a short URL where the `another.com` domain was provided.
|
||||
|
||||
These are some of the results you will get when trying to interact with them, depending on the params you provide:
|
||||
|
||||
* Providing just the `abc123` short code -> the first URL will be matched.
|
||||
* Providing just the `my-campaign` short code -> the second URL will be matched, since you did not specify a domain, therefor, Shlink looks for the one with the short code/slug `my-campaign` which is also linked to default domain (or not linked to any domain, to be more accurate).
|
||||
* Providing the `my-campaign` short code and the `another.com` domain -> The third one will be matched.
|
||||
* Providing just the `def456` short code -> Shlink will fail/not find any short URL, since there's none with the short code `def456` linked to default domain.
|
||||
* Providing the `def456` short code and the `another.com` domain -> The fourth short URL will be matched.
|
||||
* Providing any short code and the `foo.com` domain -> Again, no short URL will be found, as there's none linked to `foo.com` domain.
|
||||
|
||||
### Visits
|
||||
|
||||
Before adding support for multiple domains, you could point as many domains as you wanted to Shlink, and they would have always worked for existing short codes/slugs.
|
||||
|
||||
In order to keep backwards compatibility, Shlink's behavior when a short URL is visited is slightly different, getting to fallback in some cases.
|
||||
|
||||
Let's continue with previous example, and also consider we have three domains that will resolve to our Shlink instance, which are `example.com`, `another.com` and `foo.com`.
|
||||
|
||||
With that in mind, this is how Shlink will behave when the next short URLs are visited:
|
||||
|
||||
* `https://another.com/abc123` -> There was no short URL specifically defined for domain `another.com` and short code `abc123`, but it exists for default domain (`example.com`), so it will fall back to it and redirect to where `example.com/abc123` is configured to redirect.
|
||||
* `https://example.com/def456` -> The fall-back does not happen from default domain to specific ones, only the other way around (like in previous case). Because of that, this one will result in a not-found URL, even though the `def456` short code exists for `another.com` domain.
|
||||
* `https://foo.com/abc123` -> This will also fall-back to `example.com/abc123`, like in the first case.
|
||||
* `https://another.com/non-existing` -> The combination of `another.com` domain with the `non-existing` slug does not exist, so Shlink will try to fall-back to the same but for default domain (`example.com`). However, since that combination does not exist either, it will result in a not-found URL.
|
||||
* Any other short URL visited exactly as it was configured will, of course, resolve as expected.
|
||||
|
||||
### Special redirects
|
||||
|
||||
It is currently possible to configure some special redirects when the base domain is visited, a URL does not match, or an invalid/disabled short URL is visited.
|
||||
|
||||
Those are configured during Shlink's installation or via env vars when using the docker image.
|
||||
|
||||
Currently those are all shared for all domains serving the same Shlink instance, but the plan is to update that and allow specific ones for every existing domain.
|
||||
|
||||
> This product includes GeoLite2 data created by MaxMind, available from [https://www.maxmind.com](https://www.maxmind.com)
|
||||
|
||||
@@ -47,10 +47,10 @@
|
||||
"phly/phly-event-dispatcher": "^1.0",
|
||||
"predis/predis": "^1.1",
|
||||
"pugx/shortid-php": "^0.5",
|
||||
"shlinkio/shlink-common": "^2.5",
|
||||
"shlinkio/shlink-common": "^2.7.0",
|
||||
"shlinkio/shlink-event-dispatcher": "^1.3",
|
||||
"shlinkio/shlink-installer": "^4.0",
|
||||
"shlinkio/shlink-ip-geolocation": "^1.3",
|
||||
"shlinkio/shlink-installer": "^4.0.1",
|
||||
"shlinkio/shlink-ip-geolocation": "^1.3.1",
|
||||
"symfony/console": "^5.0",
|
||||
"symfony/filesystem": "^5.0",
|
||||
"symfony/lock": "^5.0",
|
||||
@@ -58,6 +58,7 @@
|
||||
},
|
||||
"require-dev": {
|
||||
"devster/ubench": "^2.0",
|
||||
"dms/phpunit-arraysubset-asserts": "^0.1.0",
|
||||
"eaglewu/swoole-ide-helper": "dev-master",
|
||||
"infection/infection": "^0.15.0",
|
||||
"phpstan/phpstan": "^0.12.3",
|
||||
|
||||
@@ -7,11 +7,11 @@ return [
|
||||
'ip_address_resolution' => [
|
||||
'headers_to_inspect' => [
|
||||
'CF-Connecting-IP',
|
||||
'True-Client-IP',
|
||||
'X-Real-IP',
|
||||
'Forwarded',
|
||||
'X-Forwarded-For',
|
||||
'X-Forwarded',
|
||||
'Forwarded',
|
||||
'True-Client-IP',
|
||||
'X-Real-IP',
|
||||
'X-Cluster-Client-Ip',
|
||||
'Client-Ip',
|
||||
],
|
||||
|
||||
@@ -9,6 +9,7 @@ return [
|
||||
'entity_manager' => [
|
||||
'orm' => [
|
||||
'proxies_dir' => 'data/proxies',
|
||||
'load_mappings_using_functional_style' => true,
|
||||
],
|
||||
'connection' => [
|
||||
'user' => '',
|
||||
|
||||
@@ -7,9 +7,7 @@ return [
|
||||
'geolite2' => [
|
||||
'db_location' => __DIR__ . '/../../data/GeoLite2-City.mmdb',
|
||||
'temp_dir' => sys_get_temp_dir(),
|
||||
'download_from' =>
|
||||
'https://download.maxmind.com/app/geoip_download'
|
||||
. '?edition_id=GeoLite2-City&license_key=G4Lm0C60yJsnkdPi&suffix=tar.gz',
|
||||
'license_key' => 'G4Lm0C60yJsnkdPi',
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -41,6 +41,7 @@ return [
|
||||
'level' => Logger::INFO,
|
||||
'filename' => 'data/log/shlink_log.log',
|
||||
'max_files' => 30,
|
||||
'file_permission' => 0666,
|
||||
],
|
||||
'formatter' => $formatter,
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Shlink Docker image
|
||||
|
||||
[](https://hub.docker.com/r/shlinkio/shlink/)
|
||||
[](https://hub.docker.com/r/shlinkio/shlink/)
|
||||
[](https://hub.docker.com/r/shlinkio/shlink/)
|
||||
|
||||
This image provides an easy way to set up [shlink](https://shlink.io) on a container-based runtime.
|
||||
|
||||
@@ -31,6 +31,10 @@
|
||||
},
|
||||
"meta": {
|
||||
"$ref": "./ShortUrlMeta.json"
|
||||
},
|
||||
"domain": {
|
||||
"type": "string",
|
||||
"description": "The domain in which the short URL was created. Null if it belongs to default domain."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
9
docs/swagger/parameters/domain.json
Normal file
9
docs/swagger/parameters/domain.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "domain",
|
||||
"description": "The domain in which the short code should be searched for.",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
@@ -123,7 +123,8 @@
|
||||
"validSince": "2017-01-21T00:00:00+02:00",
|
||||
"validUntil": null,
|
||||
"maxVisits": 100
|
||||
}
|
||||
},
|
||||
"domain": null
|
||||
},
|
||||
{
|
||||
"shortCode": "12Kb3",
|
||||
@@ -138,11 +139,12 @@
|
||||
"validSince": null,
|
||||
"validUntil": null,
|
||||
"maxVisits": null
|
||||
}
|
||||
},
|
||||
"domain": null
|
||||
},
|
||||
{
|
||||
"shortCode": "123bA",
|
||||
"shortUrl": "https://doma.in/123bA",
|
||||
"shortUrl": "https://example.com/123bA",
|
||||
"longUrl": "https://www.google.com",
|
||||
"dateCreated": "2015-10-01T20:34:16+02:00",
|
||||
"visitsCount": 25,
|
||||
@@ -151,7 +153,8 @@
|
||||
"validSince": "2017-01-21T00:00:00+02:00",
|
||||
"validUntil": null,
|
||||
"maxVisits": null
|
||||
}
|
||||
},
|
||||
"domain": "example.com"
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
@@ -271,7 +274,8 @@
|
||||
"validSince": "2017-01-21T00:00:00+02:00",
|
||||
"validUntil": null,
|
||||
"maxVisits": 500
|
||||
}
|
||||
},
|
||||
"domain": null
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -72,7 +72,8 @@
|
||||
"validSince": "2017-01-21T00:00:00+02:00",
|
||||
"validUntil": null,
|
||||
"maxVisits": 100
|
||||
}
|
||||
},
|
||||
"domain": null
|
||||
},
|
||||
"text/plain": "https://doma.in/abc123"
|
||||
}
|
||||
|
||||
@@ -20,13 +20,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "domain",
|
||||
"in": "query",
|
||||
"description": "The domain in which the short code should be searched for. Will fall back to default domain if not found.",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
"$ref": "../parameters/domain.json"
|
||||
}
|
||||
],
|
||||
"security": [
|
||||
@@ -58,7 +52,8 @@
|
||||
"validSince": "2017-01-21T00:00:00+02:00",
|
||||
"validUntil": null,
|
||||
"maxVisits": 100
|
||||
}
|
||||
},
|
||||
"domain": null
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -104,6 +99,9 @@
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"$ref": "../parameters/domain.json"
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
@@ -214,6 +212,9 @@
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"$ref": "../parameters/domain.json"
|
||||
}
|
||||
],
|
||||
"security": [
|
||||
|
||||
@@ -18,6 +18,9 @@
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"$ref": "../parameters/domain.json"
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
|
||||
@@ -19,6 +19,9 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"$ref": "../parameters/domain.json"
|
||||
},
|
||||
{
|
||||
"name": "startDate",
|
||||
"in": "query",
|
||||
|
||||
@@ -55,7 +55,7 @@ return [
|
||||
GeolocationDbUpdater::class => [DbUpdater::class, Reader::class, 'Shlinkio\Shlink\LocalLockFactory'],
|
||||
|
||||
Command\ShortUrl\GenerateShortUrlCommand::class => [Service\UrlShortener::class, 'config.url_shortener.domain'],
|
||||
Command\ShortUrl\ResolveUrlCommand::class => [Service\UrlShortener::class],
|
||||
Command\ShortUrl\ResolveUrlCommand::class => [Service\ShortUrl\ShortUrlResolver::class],
|
||||
Command\ShortUrl\ListShortUrlsCommand::class => [Service\ShortUrlService::class, 'config.url_shortener.domain'],
|
||||
Command\ShortUrl\GetVisitsCommand::class => [Service\VisitsTracker::class],
|
||||
Command\ShortUrl\DeleteShortUrlCommand::class => [Service\ShortUrl\DeleteShortUrlService::class],
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\Core\Exception;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Service\ShortUrl\DeleteShortUrlServiceInterface;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
@@ -40,33 +41,39 @@ class DeleteShortUrlCommand extends Command
|
||||
InputOption::VALUE_NONE,
|
||||
'Ignores the safety visits threshold check, which could make short URLs with many visits to be '
|
||||
. 'accidentally deleted',
|
||||
)
|
||||
->addOption(
|
||||
'domain',
|
||||
'd',
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'The domain if the short code does not belong to the default one',
|
||||
);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): ?int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$shortCode = $input->getArgument('shortCode');
|
||||
$identifier = ShortUrlIdentifier::fromCli($input);
|
||||
$ignoreThreshold = $input->getOption('ignore-threshold');
|
||||
|
||||
try {
|
||||
$this->runDelete($io, $shortCode, $ignoreThreshold);
|
||||
$this->runDelete($io, $identifier, $ignoreThreshold);
|
||||
return ExitCodes::EXIT_SUCCESS;
|
||||
} catch (Exception\ShortUrlNotFoundException $e) {
|
||||
$io->error($e->getMessage());
|
||||
return ExitCodes::EXIT_FAILURE;
|
||||
} catch (Exception\DeleteShortUrlException $e) {
|
||||
return $this->retry($io, $shortCode, $e->getMessage());
|
||||
return $this->retry($io, $identifier, $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function retry(SymfonyStyle $io, string $shortCode, string $warningMsg): int
|
||||
private function retry(SymfonyStyle $io, ShortUrlIdentifier $identifier, string $warningMsg): int
|
||||
{
|
||||
$io->writeln(sprintf('<bg=yellow>%s</>', $warningMsg));
|
||||
$forceDelete = $io->confirm('Do you want to delete it anyway?', false);
|
||||
|
||||
if ($forceDelete) {
|
||||
$this->runDelete($io, $shortCode, true);
|
||||
$this->runDelete($io, $identifier, true);
|
||||
} else {
|
||||
$io->warning('Short URL was not deleted.');
|
||||
}
|
||||
@@ -74,9 +81,9 @@ class DeleteShortUrlCommand extends Command
|
||||
return $forceDelete ? ExitCodes::EXIT_SUCCESS : ExitCodes::EXIT_WARNING;
|
||||
}
|
||||
|
||||
private function runDelete(SymfonyStyle $io, string $shortCode, bool $ignoreThreshold): void
|
||||
private function runDelete(SymfonyStyle $io, ShortUrlIdentifier $identifier, bool $ignoreThreshold): void
|
||||
{
|
||||
$this->deleteShortUrlService->deleteByShortCode($shortCode, $ignoreThreshold);
|
||||
$io->success(sprintf('Short URL with short code "%s" successfully deleted.', $shortCode));
|
||||
$this->deleteShortUrlService->deleteByShortCode($identifier, $ignoreThreshold);
|
||||
$io->success(sprintf('Short URL with short code "%s" successfully deleted.', $identifier->shortCode()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
|
||||
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
|
||||
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
|
||||
use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
@@ -121,14 +122,14 @@ class GenerateShortUrlCommand extends Command
|
||||
$shortUrl = $this->urlShortener->urlToShortCode(
|
||||
new Uri($longUrl),
|
||||
$tags,
|
||||
ShortUrlMeta::createFromParams(
|
||||
$input->getOption('validSince'),
|
||||
$input->getOption('validUntil'),
|
||||
$customSlug,
|
||||
$maxVisits !== null ? (int) $maxVisits : null,
|
||||
$input->getOption('findIfExists'),
|
||||
$input->getOption('domain'),
|
||||
),
|
||||
ShortUrlMeta::fromRawData([
|
||||
ShortUrlMetaInputFilter::VALID_SINCE => $input->getOption('validSince'),
|
||||
ShortUrlMetaInputFilter::VALID_UNTIL => $input->getOption('validUntil'),
|
||||
ShortUrlMetaInputFilter::CUSTOM_SLUG => $customSlug,
|
||||
ShortUrlMetaInputFilter::MAX_VISITS => $maxVisits !== null ? (int) $maxVisits : null,
|
||||
ShortUrlMetaInputFilter::FIND_IF_EXISTS => $input->getOption('findIfExists'),
|
||||
ShortUrlMetaInputFilter::DOMAIN => $input->getOption('domain'),
|
||||
]),
|
||||
);
|
||||
|
||||
$io->writeln([
|
||||
|
||||
@@ -9,10 +9,12 @@ use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
@@ -36,7 +38,8 @@ class GetVisitsCommand extends AbstractWithDateRangeCommand
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
->setDescription('Returns the detailed visits information for provided short code')
|
||||
->addArgument('shortCode', InputArgument::REQUIRED, 'The short code which visits we want to get');
|
||||
->addArgument('shortCode', InputArgument::REQUIRED, 'The short code which visits we want to get')
|
||||
->addOption('domain', 'd', InputOption::VALUE_REQUIRED, 'The domain for the short code');
|
||||
}
|
||||
|
||||
protected function getStartDateDesc(): string
|
||||
@@ -65,11 +68,11 @@ class GetVisitsCommand extends AbstractWithDateRangeCommand
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): ?int
|
||||
{
|
||||
$shortCode = $input->getArgument('shortCode');
|
||||
$identifier = ShortUrlIdentifier::fromCli($input);
|
||||
$startDate = $this->getDateOption($input, $output, 'startDate');
|
||||
$endDate = $this->getDateOption($input, $output, 'endDate');
|
||||
|
||||
$paginator = $this->visitsTracker->info($shortCode, new VisitsParams(new DateRange($startDate, $endDate)));
|
||||
$paginator = $this->visitsTracker->info($identifier, new VisitsParams(new DateRange($startDate, $endDate)));
|
||||
|
||||
$rows = map($paginator->getCurrentItems(), function (Visit $visit) {
|
||||
$rowData = $visit->jsonSerialize();
|
||||
|
||||
@@ -4,16 +4,17 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use Laminas\Paginator\Paginator;
|
||||
use Shlinkio\Shlink\CLI\Command\Util\AbstractWithDateRangeCommand;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
|
||||
use Shlinkio\Shlink\Common\Paginator\Util\PaginatorUtilsTrait;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlsOrdering;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
|
||||
use Shlinkio\Shlink\Core\Paginator\Adapter\ShortUrlRepositoryAdapter;
|
||||
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
|
||||
use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer;
|
||||
use Shlinkio\Shlink\Core\Validation\ShortUrlsParamsInputFilter;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
@@ -108,7 +109,14 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
|
||||
$orderBy = $this->processOrderBy($input);
|
||||
|
||||
do {
|
||||
$result = $this->renderPage($output, $page, $searchTerm, $tags, $showTags, $startDate, $endDate, $orderBy);
|
||||
$result = $this->renderPage($output, $showTags, ShortUrlsParams::fromRawData([
|
||||
ShortUrlsParamsInputFilter::PAGE => $page,
|
||||
ShortUrlsParamsInputFilter::SEARCH_TERM => $searchTerm,
|
||||
ShortUrlsParamsInputFilter::TAGS => $tags,
|
||||
ShortUrlsOrdering::ORDER_BY => $orderBy,
|
||||
ShortUrlsParamsInputFilter::START_DATE => $startDate !== null ? $startDate->toAtomString() : null,
|
||||
ShortUrlsParamsInputFilter::END_DATE => $endDate !== null ? $endDate->toAtomString() : null,
|
||||
]));
|
||||
$page++;
|
||||
|
||||
$continue = $this->isLastPage($result)
|
||||
@@ -122,26 +130,9 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
|
||||
return ExitCodes::EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|array|null $orderBy
|
||||
*/
|
||||
private function renderPage(
|
||||
OutputInterface $output,
|
||||
int $page,
|
||||
?string $searchTerm,
|
||||
array $tags,
|
||||
bool $showTags,
|
||||
?Chronos $startDate,
|
||||
?Chronos $endDate,
|
||||
$orderBy
|
||||
): Paginator {
|
||||
$result = $this->shortUrlService->listShortUrls(
|
||||
$page,
|
||||
$searchTerm,
|
||||
$tags,
|
||||
$orderBy,
|
||||
new DateRange($startDate, $endDate),
|
||||
);
|
||||
private function renderPage(OutputInterface $output, bool $showTags, ShortUrlsParams $params): Paginator
|
||||
{
|
||||
$result = $this->shortUrlService->listShortUrls($params);
|
||||
|
||||
$headers = ['Short code', 'Short URL', 'Long URL', 'Date created', 'Visits count'];
|
||||
if ($showTags) {
|
||||
|
||||
@@ -6,7 +6,8 @@ namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
@@ -20,12 +21,12 @@ class ResolveUrlCommand extends Command
|
||||
{
|
||||
public const NAME = 'short-url:parse';
|
||||
|
||||
private UrlShortenerInterface $urlShortener;
|
||||
private ShortUrlResolverInterface $urlResolver;
|
||||
|
||||
public function __construct(UrlShortenerInterface $urlShortener)
|
||||
public function __construct(ShortUrlResolverInterface $urlResolver)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->urlShortener = $urlShortener;
|
||||
$this->urlResolver = $urlResolver;
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
@@ -54,11 +55,9 @@ class ResolveUrlCommand extends Command
|
||||
protected function execute(InputInterface $input, OutputInterface $output): ?int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$shortCode = $input->getArgument('shortCode');
|
||||
$domain = $input->getOption('domain');
|
||||
|
||||
try {
|
||||
$url = $this->urlShortener->shortCodeToUrl($shortCode, $domain);
|
||||
$url = $this->urlResolver->resolveShortUrl(ShortUrlIdentifier::fromCli($input));
|
||||
$output->writeln(sprintf('Long URL: <info>%s</info>', $url->getLongUrl()));
|
||||
return ExitCodes::EXIT_SUCCESS;
|
||||
} catch (ShortUrlNotFoundException $e) {
|
||||
|
||||
@@ -9,6 +9,7 @@ use Prophecy\Argument;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\ShortUrl\DeleteShortUrlCommand;
|
||||
use Shlinkio\Shlink\Core\Exception;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Service\ShortUrl\DeleteShortUrlServiceInterface;
|
||||
use Symfony\Component\Console\Application;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
@@ -38,8 +39,10 @@ class DeleteShortUrlCommandTest extends TestCase
|
||||
public function successMessageIsPrintedIfUrlIsProperlyDeleted(): void
|
||||
{
|
||||
$shortCode = 'abc123';
|
||||
$deleteByShortCode = $this->service->deleteByShortCode($shortCode, false)->will(function (): void {
|
||||
});
|
||||
$deleteByShortCode = $this->service->deleteByShortCode(new ShortUrlIdentifier($shortCode), false)->will(
|
||||
function (): void {
|
||||
},
|
||||
);
|
||||
|
||||
$this->commandTester->execute(['shortCode' => $shortCode]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
@@ -55,8 +58,9 @@ class DeleteShortUrlCommandTest extends TestCase
|
||||
public function invalidShortCodePrintsMessage(): void
|
||||
{
|
||||
$shortCode = 'abc123';
|
||||
$deleteByShortCode = $this->service->deleteByShortCode($shortCode, false)->willThrow(
|
||||
Exception\ShortUrlNotFoundException::fromNotFoundShortCode($shortCode),
|
||||
$identifier = new ShortUrlIdentifier($shortCode);
|
||||
$deleteByShortCode = $this->service->deleteByShortCode($identifier, false)->willThrow(
|
||||
Exception\ShortUrlNotFoundException::fromNotFound($identifier),
|
||||
);
|
||||
|
||||
$this->commandTester->execute(['shortCode' => $shortCode]);
|
||||
@@ -76,7 +80,8 @@ class DeleteShortUrlCommandTest extends TestCase
|
||||
string $expectedMessage
|
||||
): void {
|
||||
$shortCode = 'abc123';
|
||||
$deleteByShortCode = $this->service->deleteByShortCode($shortCode, Argument::type('bool'))->will(
|
||||
$identifier = new ShortUrlIdentifier($shortCode);
|
||||
$deleteByShortCode = $this->service->deleteByShortCode($identifier, Argument::type('bool'))->will(
|
||||
function (array $args) use ($shortCode): void {
|
||||
$ignoreThreshold = array_pop($args);
|
||||
|
||||
@@ -109,7 +114,7 @@ class DeleteShortUrlCommandTest extends TestCase
|
||||
public function deleteIsNotRetriedWhenThresholdIsReachedAndQuestionIsDeclined(): void
|
||||
{
|
||||
$shortCode = 'abc123';
|
||||
$deleteByShortCode = $this->service->deleteByShortCode($shortCode, false)->willThrow(
|
||||
$deleteByShortCode = $this->service->deleteByShortCode(new ShortUrlIdentifier($shortCode), false)->willThrow(
|
||||
Exception\DeleteShortUrlException::fromVisitsThreshold(10, $shortCode),
|
||||
);
|
||||
$this->commandTester->setInputs(['no']);
|
||||
|
||||
@@ -15,6 +15,7 @@ use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Entity\VisitLocation;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
|
||||
@@ -42,9 +43,12 @@ class GetVisitsCommandTest extends TestCase
|
||||
public function noDateFlagsTriesToListWithoutDateRange(): void
|
||||
{
|
||||
$shortCode = 'abc123';
|
||||
$this->visitsTracker->info($shortCode, new VisitsParams(new DateRange(null, null)))->willReturn(
|
||||
new Paginator(new ArrayAdapter([])),
|
||||
)->shouldBeCalledOnce();
|
||||
$this->visitsTracker->info(
|
||||
new ShortUrlIdentifier($shortCode),
|
||||
new VisitsParams(new DateRange(null, null)),
|
||||
)
|
||||
->willReturn(new Paginator(new ArrayAdapter([])))
|
||||
->shouldBeCalledOnce();
|
||||
|
||||
$this->commandTester->execute(['shortCode' => $shortCode]);
|
||||
}
|
||||
@@ -56,7 +60,7 @@ class GetVisitsCommandTest extends TestCase
|
||||
$startDate = '2016-01-01';
|
||||
$endDate = '2016-02-01';
|
||||
$this->visitsTracker->info(
|
||||
$shortCode,
|
||||
new ShortUrlIdentifier($shortCode),
|
||||
new VisitsParams(new DateRange(Chronos::parse($startDate), Chronos::parse($endDate))),
|
||||
)
|
||||
->willReturn(new Paginator(new ArrayAdapter([])))
|
||||
@@ -74,7 +78,7 @@ class GetVisitsCommandTest extends TestCase
|
||||
{
|
||||
$shortCode = 'abc123';
|
||||
$startDate = 'foo';
|
||||
$info = $this->visitsTracker->info($shortCode, new VisitsParams(new DateRange()))
|
||||
$info = $this->visitsTracker->info(new ShortUrlIdentifier($shortCode), new VisitsParams(new DateRange()))
|
||||
->willReturn(new Paginator(new ArrayAdapter([])));
|
||||
|
||||
$this->commandTester->execute([
|
||||
@@ -94,7 +98,7 @@ class GetVisitsCommandTest extends TestCase
|
||||
public function outputIsProperlyGenerated(): void
|
||||
{
|
||||
$shortCode = 'abc123';
|
||||
$this->visitsTracker->info($shortCode, Argument::any())->willReturn(
|
||||
$this->visitsTracker->info(new ShortUrlIdentifier($shortCode), Argument::any())->willReturn(
|
||||
new Paginator(new ArrayAdapter([
|
||||
(new Visit(new ShortUrl(''), new Visitor('bar', 'foo', '')))->locate(
|
||||
new VisitLocation(new Location('', 'Spain', '', '', 0, 0, '')),
|
||||
|
||||
@@ -11,8 +11,8 @@ use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\ShortUrl\ListShortUrlsCommand;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
|
||||
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
|
||||
use Symfony\Component\Console\Application;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
@@ -64,7 +64,7 @@ class ListShortUrlsCommandTest extends TestCase
|
||||
$data[] = new ShortUrl('url_' . $i);
|
||||
}
|
||||
|
||||
$this->shortUrlService->listShortUrls(1, null, [], null, new DateRange())
|
||||
$this->shortUrlService->listShortUrls(ShortUrlsParams::emptyInstance())
|
||||
->willReturn(new Paginator(new ArrayAdapter($data)))
|
||||
->shouldBeCalledOnce();
|
||||
|
||||
@@ -85,7 +85,7 @@ class ListShortUrlsCommandTest extends TestCase
|
||||
public function passingPageWillMakeListStartOnThatPage(): void
|
||||
{
|
||||
$page = 5;
|
||||
$this->shortUrlService->listShortUrls($page, null, [], null, new DateRange())
|
||||
$this->shortUrlService->listShortUrls(ShortUrlsParams::fromRawData(['page' => $page]))
|
||||
->willReturn(new Paginator(new ArrayAdapter()))
|
||||
->shouldBeCalledOnce();
|
||||
|
||||
@@ -96,7 +96,7 @@ class ListShortUrlsCommandTest extends TestCase
|
||||
/** @test */
|
||||
public function ifTagsFlagIsProvidedTagsColumnIsIncluded(): void
|
||||
{
|
||||
$this->shortUrlService->listShortUrls(1, null, [], null, new DateRange())
|
||||
$this->shortUrlService->listShortUrls(ShortUrlsParams::emptyInstance())
|
||||
->willReturn(new Paginator(new ArrayAdapter()))
|
||||
->shouldBeCalledOnce();
|
||||
|
||||
@@ -115,10 +115,16 @@ class ListShortUrlsCommandTest extends TestCase
|
||||
?int $page,
|
||||
?string $searchTerm,
|
||||
array $tags,
|
||||
?DateRange $dateRange
|
||||
?string $startDate = null,
|
||||
?string $endDate = null
|
||||
): void {
|
||||
$listShortUrls = $this->shortUrlService->listShortUrls($page, $searchTerm, $tags, null, $dateRange)
|
||||
->willReturn(new Paginator(new ArrayAdapter()));
|
||||
$listShortUrls = $this->shortUrlService->listShortUrls(ShortUrlsParams::fromRawData([
|
||||
'page' => $page,
|
||||
'searchTerm' => $searchTerm,
|
||||
'tags' => $tags,
|
||||
'startDate' => $startDate !== null ? Chronos::parse($startDate)->toAtomString() : null,
|
||||
'endDate' => $endDate !== null ? Chronos::parse($endDate)->toAtomString() : null,
|
||||
]))->willReturn(new Paginator(new ArrayAdapter()));
|
||||
|
||||
$this->commandTester->setInputs(['n']);
|
||||
$this->commandTester->execute($commandArgs);
|
||||
@@ -128,36 +134,37 @@ class ListShortUrlsCommandTest extends TestCase
|
||||
|
||||
public function provideArgs(): iterable
|
||||
{
|
||||
yield [[], 1, null, [], new DateRange()];
|
||||
yield [['--page' => $page = 3], $page, null, [], new DateRange()];
|
||||
yield [['--searchTerm' => $searchTerm = 'search this'], 1, $searchTerm, [], new DateRange()];
|
||||
yield [[], 1, null, []];
|
||||
yield [['--page' => $page = 3], $page, null, []];
|
||||
yield [['--searchTerm' => $searchTerm = 'search this'], 1, $searchTerm, []];
|
||||
yield [
|
||||
['--page' => $page = 3, '--searchTerm' => $searchTerm = 'search this', '--tags' => $tags = 'foo,bar'],
|
||||
$page,
|
||||
$searchTerm,
|
||||
explode(',', $tags),
|
||||
new DateRange(),
|
||||
];
|
||||
yield [
|
||||
['--startDate' => $startDate = '2019-01-01'],
|
||||
1,
|
||||
null,
|
||||
[],
|
||||
new DateRange(Chronos::parse($startDate)),
|
||||
$startDate,
|
||||
];
|
||||
yield [
|
||||
['--endDate' => $endDate = '2020-05-23'],
|
||||
1,
|
||||
null,
|
||||
[],
|
||||
new DateRange(null, Chronos::parse($endDate)),
|
||||
null,
|
||||
$endDate,
|
||||
];
|
||||
yield [
|
||||
['--startDate' => $startDate = '2019-01-01', '--endDate' => $endDate = '2020-05-23'],
|
||||
1,
|
||||
null,
|
||||
[],
|
||||
new DateRange(Chronos::parse($startDate), Chronos::parse($endDate)),
|
||||
$startDate,
|
||||
$endDate,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -168,8 +175,9 @@ class ListShortUrlsCommandTest extends TestCase
|
||||
*/
|
||||
public function orderByIsProperlyComputed(array $commandArgs, $expectedOrderBy): void
|
||||
{
|
||||
$listShortUrls = $this->shortUrlService->listShortUrls(1, null, [], $expectedOrderBy, new DateRange())
|
||||
->willReturn(new Paginator(new ArrayAdapter()));
|
||||
$listShortUrls = $this->shortUrlService->listShortUrls(ShortUrlsParams::fromRawData([
|
||||
'orderBy' => $expectedOrderBy,
|
||||
]))->willReturn(new Paginator(new ArrayAdapter()));
|
||||
|
||||
$this->commandTester->setInputs(['n']);
|
||||
$this->commandTester->execute($commandArgs);
|
||||
|
||||
@@ -9,7 +9,8 @@ use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\ShortUrl\ResolveUrlCommand;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Service\UrlShortener;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
|
||||
use Symfony\Component\Console\Application;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
@@ -20,12 +21,12 @@ use const PHP_EOL;
|
||||
class ResolveUrlCommandTest extends TestCase
|
||||
{
|
||||
private CommandTester $commandTester;
|
||||
private ObjectProphecy $urlShortener;
|
||||
private ObjectProphecy $urlResolver;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->urlShortener = $this->prophesize(UrlShortener::class);
|
||||
$command = new ResolveUrlCommand($this->urlShortener->reveal());
|
||||
$this->urlResolver = $this->prophesize(ShortUrlResolverInterface::class);
|
||||
$command = new ResolveUrlCommand($this->urlResolver->reveal());
|
||||
$app = new Application();
|
||||
$app->add($command);
|
||||
|
||||
@@ -38,8 +39,8 @@ class ResolveUrlCommandTest extends TestCase
|
||||
$shortCode = 'abc123';
|
||||
$expectedUrl = 'http://domain.com/foo/bar';
|
||||
$shortUrl = new ShortUrl($expectedUrl);
|
||||
$this->urlShortener->shortCodeToUrl($shortCode, null)->willReturn($shortUrl)
|
||||
->shouldBeCalledOnce();
|
||||
$this->urlResolver->resolveShortUrl(new ShortUrlIdentifier($shortCode))->willReturn($shortUrl)
|
||||
->shouldBeCalledOnce();
|
||||
|
||||
$this->commandTester->execute(['shortCode' => $shortCode]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
@@ -49,9 +50,11 @@ class ResolveUrlCommandTest extends TestCase
|
||||
/** @test */
|
||||
public function incorrectShortCodeOutputsErrorMessage(): void
|
||||
{
|
||||
$shortCode = 'abc123';
|
||||
$this->urlShortener->shortCodeToUrl($shortCode, null)
|
||||
->willThrow(ShortUrlNotFoundException::fromNotFoundShortCode($shortCode))
|
||||
$identifier = new ShortUrlIdentifier('abc123');
|
||||
$shortCode = $identifier->shortCode();
|
||||
|
||||
$this->urlResolver->resolveShortUrl($identifier)
|
||||
->willThrow(ShortUrlNotFoundException::fromNotFound($identifier))
|
||||
->shouldBeCalledOnce();
|
||||
|
||||
$this->commandTester->execute(['shortCode' => $shortCode]);
|
||||
|
||||
@@ -30,6 +30,7 @@ return [
|
||||
Service\VisitService::class => ConfigAbstractFactory::class,
|
||||
Service\Tag\TagService::class => ConfigAbstractFactory::class,
|
||||
Service\ShortUrl\DeleteShortUrlService::class => ConfigAbstractFactory::class,
|
||||
Service\ShortUrl\ShortUrlResolver::class => ConfigAbstractFactory::class,
|
||||
|
||||
Util\UrlValidator::class => ConfigAbstractFactory::class,
|
||||
|
||||
@@ -52,26 +53,35 @@ return [
|
||||
|
||||
Service\UrlShortener::class => [Util\UrlValidator::class, 'em', Options\UrlShortenerOptions::class],
|
||||
Service\VisitsTracker::class => ['em', EventDispatcherInterface::class],
|
||||
Service\ShortUrlService::class => ['em'],
|
||||
Service\ShortUrlService::class => ['em', Service\ShortUrl\ShortUrlResolver::class],
|
||||
Service\VisitService::class => ['em'],
|
||||
Service\Tag\TagService::class => ['em'],
|
||||
Service\ShortUrl\DeleteShortUrlService::class => ['em', Options\DeleteShortUrlsOptions::class],
|
||||
Service\ShortUrl\DeleteShortUrlService::class => [
|
||||
'em',
|
||||
Options\DeleteShortUrlsOptions::class,
|
||||
Service\ShortUrl\ShortUrlResolver::class,
|
||||
],
|
||||
Service\ShortUrl\ShortUrlResolver::class => ['em'],
|
||||
|
||||
Util\UrlValidator::class => ['httpClient'],
|
||||
|
||||
Action\RedirectAction::class => [
|
||||
Service\UrlShortener::class,
|
||||
Service\ShortUrl\ShortUrlResolver::class,
|
||||
Service\VisitsTracker::class,
|
||||
Options\AppOptions::class,
|
||||
'Logger_Shlink',
|
||||
],
|
||||
Action\PixelAction::class => [
|
||||
Service\UrlShortener::class,
|
||||
Service\ShortUrl\ShortUrlResolver::class,
|
||||
Service\VisitsTracker::class,
|
||||
Options\AppOptions::class,
|
||||
'Logger_Shlink',
|
||||
],
|
||||
Action\QrCodeAction::class => [RouterInterface::class, Service\UrlShortener::class, 'Logger_Shlink'],
|
||||
Action\QrCodeAction::class => [
|
||||
RouterInterface::class,
|
||||
Service\ShortUrl\ShortUrlResolver::class,
|
||||
'Logger_Shlink',
|
||||
],
|
||||
|
||||
Middleware\QrCodeCacheMiddleware::class => [Cache::class],
|
||||
],
|
||||
|
||||
@@ -6,20 +6,21 @@ namespace Shlinkio\Shlink\Core;
|
||||
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping\Builder\ClassMetadataBuilder;
|
||||
use Doctrine\ORM\Mapping\ClassMetadata; // @codingStandardsIgnoreLine
|
||||
use Doctrine\ORM\Mapping\ClassMetadata;
|
||||
|
||||
/** @var $metadata ClassMetadata */ // @codingStandardsIgnoreLine
|
||||
$builder = new ClassMetadataBuilder($metadata);
|
||||
return static function (ClassMetadata $metadata, array $emConfig): void {
|
||||
$builder = new ClassMetadataBuilder($metadata);
|
||||
|
||||
$builder->setTable('domains');
|
||||
$builder->setTable(determineTableName('domains', $emConfig));
|
||||
|
||||
$builder->createField('id', Types::BIGINT)
|
||||
->columnName('id')
|
||||
->makePrimaryKey()
|
||||
->generatedValue('IDENTITY')
|
||||
->option('unsigned', true)
|
||||
->build();
|
||||
$builder->createField('id', Types::BIGINT)
|
||||
->columnName('id')
|
||||
->makePrimaryKey()
|
||||
->generatedValue('IDENTITY')
|
||||
->option('unsigned', true)
|
||||
->build();
|
||||
|
||||
$builder->createField('authority', Types::STRING)
|
||||
->unique()
|
||||
->build();
|
||||
$builder->createField('authority', Types::STRING)
|
||||
->unique()
|
||||
->build();
|
||||
};
|
||||
|
||||
@@ -6,65 +6,66 @@ namespace Shlinkio\Shlink\Core;
|
||||
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping\Builder\ClassMetadataBuilder;
|
||||
use Doctrine\ORM\Mapping\ClassMetadata; // @codingStandardsIgnoreLine
|
||||
use Doctrine\ORM\Mapping\ClassMetadata;
|
||||
use Shlinkio\Shlink\Common\Doctrine\Type\ChronosDateTimeType;
|
||||
|
||||
/** @var $metadata ClassMetadata */ // @codingStandardsIgnoreLine
|
||||
$builder = new ClassMetadataBuilder($metadata);
|
||||
return static function (ClassMetadata $metadata, array $emConfig): void {
|
||||
$builder = new ClassMetadataBuilder($metadata);
|
||||
|
||||
$builder->setTable('short_urls')
|
||||
->setCustomRepositoryClass(Repository\ShortUrlRepository::class);
|
||||
$builder->setTable(determineTableName('short_urls', $emConfig))
|
||||
->setCustomRepositoryClass(Repository\ShortUrlRepository::class);
|
||||
|
||||
$builder->createField('id', Types::BIGINT)
|
||||
->columnName('id')
|
||||
->makePrimaryKey()
|
||||
->generatedValue('IDENTITY')
|
||||
->option('unsigned', true)
|
||||
->build();
|
||||
$builder->createField('id', Types::BIGINT)
|
||||
->columnName('id')
|
||||
->makePrimaryKey()
|
||||
->generatedValue('IDENTITY')
|
||||
->option('unsigned', true)
|
||||
->build();
|
||||
|
||||
$builder->createField('longUrl', Types::STRING)
|
||||
->columnName('original_url')
|
||||
->length(2048)
|
||||
->build();
|
||||
$builder->createField('longUrl', Types::STRING)
|
||||
->columnName('original_url')
|
||||
->length(2048)
|
||||
->build();
|
||||
|
||||
$builder->createField('shortCode', Types::STRING)
|
||||
->columnName('short_code')
|
||||
->length(255)
|
||||
->build();
|
||||
$builder->createField('shortCode', Types::STRING)
|
||||
->columnName('short_code')
|
||||
->length(255)
|
||||
->build();
|
||||
|
||||
$builder->createField('dateCreated', ChronosDateTimeType::CHRONOS_DATETIME)
|
||||
->columnName('date_created')
|
||||
->build();
|
||||
$builder->createField('dateCreated', ChronosDateTimeType::CHRONOS_DATETIME)
|
||||
->columnName('date_created')
|
||||
->build();
|
||||
|
||||
$builder->createField('validSince', ChronosDateTimeType::CHRONOS_DATETIME)
|
||||
->columnName('valid_since')
|
||||
->nullable()
|
||||
->build();
|
||||
$builder->createField('validSince', ChronosDateTimeType::CHRONOS_DATETIME)
|
||||
->columnName('valid_since')
|
||||
->nullable()
|
||||
->build();
|
||||
|
||||
$builder->createField('validUntil', ChronosDateTimeType::CHRONOS_DATETIME)
|
||||
->columnName('valid_until')
|
||||
->nullable()
|
||||
->build();
|
||||
$builder->createField('validUntil', ChronosDateTimeType::CHRONOS_DATETIME)
|
||||
->columnName('valid_until')
|
||||
->nullable()
|
||||
->build();
|
||||
|
||||
$builder->createField('maxVisits', Types::INTEGER)
|
||||
->columnName('max_visits')
|
||||
->nullable()
|
||||
->build();
|
||||
$builder->createField('maxVisits', Types::INTEGER)
|
||||
->columnName('max_visits')
|
||||
->nullable()
|
||||
->build();
|
||||
|
||||
$builder->createOneToMany('visits', Entity\Visit::class)
|
||||
->mappedBy('shortUrl')
|
||||
->fetchExtraLazy()
|
||||
->build();
|
||||
$builder->createOneToMany('visits', Entity\Visit::class)
|
||||
->mappedBy('shortUrl')
|
||||
->fetchExtraLazy()
|
||||
->build();
|
||||
|
||||
$builder->createManyToMany('tags', Entity\Tag::class)
|
||||
->setJoinTable('short_urls_in_tags')
|
||||
->addInverseJoinColumn('tag_id', 'id', true, false, 'CASCADE')
|
||||
->addJoinColumn('short_url_id', 'id', true, false, 'CASCADE')
|
||||
->build();
|
||||
$builder->createManyToMany('tags', Entity\Tag::class)
|
||||
->setJoinTable(determineTableName('short_urls_in_tags', $emConfig))
|
||||
->addInverseJoinColumn('tag_id', 'id', true, false, 'CASCADE')
|
||||
->addJoinColumn('short_url_id', 'id', true, false, 'CASCADE')
|
||||
->build();
|
||||
|
||||
$builder->createManyToOne('domain', Entity\Domain::class)
|
||||
->addJoinColumn('domain_id', 'id', true, false, 'RESTRICT')
|
||||
->cascadePersist()
|
||||
->build();
|
||||
$builder->createManyToOne('domain', Entity\Domain::class)
|
||||
->addJoinColumn('domain_id', 'id', true, false, 'RESTRICT')
|
||||
->cascadePersist()
|
||||
->build();
|
||||
|
||||
$builder->addUniqueConstraint(['short_code', 'domain_id'], 'unique_short_code_plus_domain');
|
||||
$builder->addUniqueConstraint(['short_code', 'domain_id'], 'unique_short_code_plus_domain');
|
||||
};
|
||||
|
||||
@@ -6,21 +6,22 @@ namespace Shlinkio\Shlink\Core;
|
||||
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping\Builder\ClassMetadataBuilder;
|
||||
use Doctrine\ORM\Mapping\ClassMetadata; // @codingStandardsIgnoreLine
|
||||
use Doctrine\ORM\Mapping\ClassMetadata;
|
||||
|
||||
/** @var $metadata ClassMetadata */ // @codingStandardsIgnoreLine
|
||||
$builder = new ClassMetadataBuilder($metadata);
|
||||
return static function (ClassMetadata $metadata, array $emConfig): void {
|
||||
$builder = new ClassMetadataBuilder($metadata);
|
||||
|
||||
$builder->setTable('tags')
|
||||
->setCustomRepositoryClass(Repository\TagRepository::class);
|
||||
$builder->setTable(determineTableName('tags', $emConfig))
|
||||
->setCustomRepositoryClass(Repository\TagRepository::class);
|
||||
|
||||
$builder->createField('id', Types::BIGINT)
|
||||
->columnName('id')
|
||||
->makePrimaryKey()
|
||||
->generatedValue('IDENTITY')
|
||||
->option('unsigned', true)
|
||||
->build();
|
||||
$builder->createField('id', Types::BIGINT)
|
||||
->columnName('id')
|
||||
->makePrimaryKey()
|
||||
->generatedValue('IDENTITY')
|
||||
->option('unsigned', true)
|
||||
->build();
|
||||
|
||||
$builder->createField('name', Types::STRING)
|
||||
->unique()
|
||||
->build();
|
||||
$builder->createField('name', Types::STRING)
|
||||
->unique()
|
||||
->build();
|
||||
};
|
||||
|
||||
@@ -6,49 +6,50 @@ namespace Shlinkio\Shlink\Core;
|
||||
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping\Builder\ClassMetadataBuilder;
|
||||
use Doctrine\ORM\Mapping\ClassMetadata; // @codingStandardsIgnoreLine
|
||||
use Doctrine\ORM\Mapping\ClassMetadata;
|
||||
use Shlinkio\Shlink\Common\Doctrine\Type\ChronosDateTimeType;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
|
||||
/** @var $metadata ClassMetadata */ // @codingStandardsIgnoreLine
|
||||
$builder = new ClassMetadataBuilder($metadata);
|
||||
return static function (ClassMetadata $metadata, array $emConfig): void {
|
||||
$builder = new ClassMetadataBuilder($metadata);
|
||||
|
||||
$builder->setTable('visits')
|
||||
->setCustomRepositoryClass(Repository\VisitRepository::class);
|
||||
$builder->setTable(determineTableName('visits', $emConfig))
|
||||
->setCustomRepositoryClass(Repository\VisitRepository::class);
|
||||
|
||||
$builder->createField('id', Types::BIGINT)
|
||||
->columnName('id')
|
||||
->makePrimaryKey()
|
||||
->generatedValue('IDENTITY')
|
||||
->option('unsigned', true)
|
||||
->build();
|
||||
$builder->createField('id', Types::BIGINT)
|
||||
->columnName('id')
|
||||
->makePrimaryKey()
|
||||
->generatedValue('IDENTITY')
|
||||
->option('unsigned', true)
|
||||
->build();
|
||||
|
||||
$builder->createField('referer', Types::STRING)
|
||||
->nullable()
|
||||
->length(Visitor::REFERER_MAX_LENGTH)
|
||||
->build();
|
||||
$builder->createField('referer', Types::STRING)
|
||||
->nullable()
|
||||
->length(Visitor::REFERER_MAX_LENGTH)
|
||||
->build();
|
||||
|
||||
$builder->createField('date', ChronosDateTimeType::CHRONOS_DATETIME)
|
||||
->columnName('`date`')
|
||||
->build();
|
||||
$builder->createField('date', ChronosDateTimeType::CHRONOS_DATETIME)
|
||||
->columnName('`date`')
|
||||
->build();
|
||||
|
||||
$builder->createField('remoteAddr', Types::STRING)
|
||||
->columnName('remote_addr')
|
||||
->length(Visitor::REMOTE_ADDRESS_MAX_LENGTH)
|
||||
->nullable()
|
||||
->build();
|
||||
$builder->createField('remoteAddr', Types::STRING)
|
||||
->columnName('remote_addr')
|
||||
->length(Visitor::REMOTE_ADDRESS_MAX_LENGTH)
|
||||
->nullable()
|
||||
->build();
|
||||
|
||||
$builder->createField('userAgent', Types::STRING)
|
||||
->columnName('user_agent')
|
||||
->length(Visitor::USER_AGENT_MAX_LENGTH)
|
||||
->nullable()
|
||||
->build();
|
||||
$builder->createField('userAgent', Types::STRING)
|
||||
->columnName('user_agent')
|
||||
->length(Visitor::USER_AGENT_MAX_LENGTH)
|
||||
->nullable()
|
||||
->build();
|
||||
|
||||
$builder->createManyToOne('shortUrl', Entity\ShortUrl::class)
|
||||
->addJoinColumn('short_url_id', 'id', false, false, 'CASCADE')
|
||||
->build();
|
||||
$builder->createManyToOne('shortUrl', Entity\ShortUrl::class)
|
||||
->addJoinColumn('short_url_id', 'id', false, false, 'CASCADE')
|
||||
->build();
|
||||
|
||||
$builder->createManyToOne('visitLocation', Entity\VisitLocation::class)
|
||||
->addJoinColumn('visit_location_id', 'id', true, false, 'Set NULL')
|
||||
->cascadePersist()
|
||||
->build();
|
||||
$builder->createManyToOne('visitLocation', Entity\VisitLocation::class)
|
||||
->addJoinColumn('visit_location_id', 'id', true, false, 'Set NULL')
|
||||
->cascadePersist()
|
||||
->build();
|
||||
};
|
||||
|
||||
@@ -6,41 +6,42 @@ namespace Shlinkio\Shlink\Core;
|
||||
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping\Builder\ClassMetadataBuilder;
|
||||
use Doctrine\ORM\Mapping\ClassMetadata; // @codingStandardsIgnoreLine
|
||||
use Doctrine\ORM\Mapping\ClassMetadata;
|
||||
|
||||
/** @var $metadata ClassMetadata */ // @codingStandardsIgnoreLine
|
||||
$builder = new ClassMetadataBuilder($metadata);
|
||||
return static function (ClassMetadata $metadata, array $emConfig): void {
|
||||
$builder = new ClassMetadataBuilder($metadata);
|
||||
|
||||
$builder->setTable('visit_locations');
|
||||
$builder->setTable(determineTableName('visit_locations', $emConfig));
|
||||
|
||||
$builder->createField('id', Types::BIGINT)
|
||||
->columnName('id')
|
||||
->makePrimaryKey()
|
||||
->generatedValue('IDENTITY')
|
||||
->option('unsigned', true)
|
||||
->build();
|
||||
|
||||
$columns = [
|
||||
'country_code' => 'countryCode',
|
||||
'country_name' => 'countryName',
|
||||
'region_name' => 'regionName',
|
||||
'city_name' => 'cityName',
|
||||
'timezone' => 'timezone',
|
||||
];
|
||||
|
||||
foreach ($columns as $columnName => $fieldName) {
|
||||
$builder->createField($fieldName, Types::STRING)
|
||||
->columnName($columnName)
|
||||
->nullable()
|
||||
$builder->createField('id', Types::BIGINT)
|
||||
->columnName('id')
|
||||
->makePrimaryKey()
|
||||
->generatedValue('IDENTITY')
|
||||
->option('unsigned', true)
|
||||
->build();
|
||||
}
|
||||
|
||||
$builder->createField('latitude', Types::FLOAT)
|
||||
->columnName('lat')
|
||||
->nullable(false)
|
||||
->build();
|
||||
$columns = [
|
||||
'country_code' => 'countryCode',
|
||||
'country_name' => 'countryName',
|
||||
'region_name' => 'regionName',
|
||||
'city_name' => 'cityName',
|
||||
'timezone' => 'timezone',
|
||||
];
|
||||
|
||||
$builder->createField('longitude', Types::FLOAT)
|
||||
->columnName('lon')
|
||||
->nullable(false)
|
||||
->build();
|
||||
foreach ($columns as $columnName => $fieldName) {
|
||||
$builder->createField($fieldName, Types::STRING)
|
||||
->columnName($columnName)
|
||||
->nullable()
|
||||
->build();
|
||||
}
|
||||
|
||||
$builder->createField('latitude', Types::FLOAT)
|
||||
->columnName('lat')
|
||||
->nullable(false)
|
||||
->build();
|
||||
|
||||
$builder->createField('longitude', Types::FLOAT)
|
||||
->columnName('lon')
|
||||
->nullable(false)
|
||||
->build();
|
||||
};
|
||||
|
||||
@@ -4,8 +4,12 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use DateTimeInterface;
|
||||
use PUGX\Shortid\Factory as ShortIdFactory;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
function generateRandomShortCode(int $length = 5): string
|
||||
{
|
||||
static $shortIdFactory;
|
||||
@@ -16,3 +20,36 @@ function generateRandomShortCode(int $length = 5): string
|
||||
$alphabet = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
return $shortIdFactory->generate($length, $alphabet)->serialize();
|
||||
}
|
||||
|
||||
function parseDateFromQuery(array $query, string $dateName): ?Chronos
|
||||
{
|
||||
return ! isset($query[$dateName]) || empty($query[$dateName]) ? null : Chronos::parse($query[$dateName]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|DateTimeInterface|Chronos|null $date
|
||||
*/
|
||||
function parseDateField($date): ?Chronos
|
||||
{
|
||||
if ($date === null || $date instanceof Chronos) {
|
||||
return $date;
|
||||
}
|
||||
|
||||
if ($date instanceof DateTimeInterface) {
|
||||
return Chronos::instance($date);
|
||||
}
|
||||
|
||||
return Chronos::parse($date);
|
||||
}
|
||||
|
||||
function determineTableName(string $tableName, array $emConfig = []): string
|
||||
{
|
||||
$schema = $emConfig['connection']['schema'] ?? null;
|
||||
// $tablePrefix = $emConfig['connection']['table_prefix'] ?? null; // TODO
|
||||
|
||||
if ($schema === null) {
|
||||
return $tableName;
|
||||
}
|
||||
|
||||
return sprintf('%s.%s', $schema, $tableName);
|
||||
}
|
||||
|
||||
@@ -13,9 +13,10 @@ use Psr\Log\LoggerInterface;
|
||||
use Psr\Log\NullLogger;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
use Shlinkio\Shlink\Core\Options\AppOptions;
|
||||
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
|
||||
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
|
||||
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
|
||||
|
||||
use function array_key_exists;
|
||||
@@ -25,18 +26,18 @@ use function GuzzleHttp\Psr7\parse_query;
|
||||
|
||||
abstract class AbstractTrackingAction implements MiddlewareInterface
|
||||
{
|
||||
private UrlShortenerInterface $urlShortener;
|
||||
private ShortUrlResolverInterface $urlResolver;
|
||||
private VisitsTrackerInterface $visitTracker;
|
||||
private AppOptions $appOptions;
|
||||
private LoggerInterface $logger;
|
||||
|
||||
public function __construct(
|
||||
UrlShortenerInterface $urlShortener,
|
||||
ShortUrlResolverInterface $urlResolver,
|
||||
VisitsTrackerInterface $visitTracker,
|
||||
AppOptions $appOptions,
|
||||
?LoggerInterface $logger = null
|
||||
) {
|
||||
$this->urlShortener = $urlShortener;
|
||||
$this->urlResolver = $urlResolver;
|
||||
$this->visitTracker = $visitTracker;
|
||||
$this->appOptions = $appOptions;
|
||||
$this->logger = $logger ?: new NullLogger();
|
||||
@@ -44,17 +45,16 @@ abstract class AbstractTrackingAction implements MiddlewareInterface
|
||||
|
||||
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||
{
|
||||
$shortCode = $request->getAttribute('shortCode', '');
|
||||
$domain = $request->getUri()->getAuthority();
|
||||
$identifier = ShortUrlIdentifier::fromRedirectRequest($request);
|
||||
$query = $request->getQueryParams();
|
||||
$disableTrackParam = $this->appOptions->getDisableTrackParam();
|
||||
|
||||
try {
|
||||
$url = $this->urlShortener->shortCodeToUrl($shortCode, $domain);
|
||||
$url = $this->urlResolver->resolveEnabledShortUrl($identifier);
|
||||
|
||||
// Track visit to this short code
|
||||
if ($disableTrackParam === null || ! array_key_exists($disableTrackParam, $query)) {
|
||||
$this->visitTracker->track($shortCode, Visitor::fromRequest($request));
|
||||
$this->visitTracker->track($url, Visitor::fromRequest($request));
|
||||
}
|
||||
|
||||
return $this->createSuccessResp($this->buildUrlToRedirectTo($url, $query, $disableTrackParam));
|
||||
|
||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
||||
namespace Shlinkio\Shlink\Core\Action;
|
||||
|
||||
use Endroid\QrCode\QrCode;
|
||||
use Mezzio\Router\Exception\RuntimeException;
|
||||
use Mezzio\Router\RouterInterface;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
@@ -15,7 +14,8 @@ use Psr\Log\LoggerInterface;
|
||||
use Psr\Log\NullLogger;
|
||||
use Shlinkio\Shlink\Common\Response\QrCodeResponse;
|
||||
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
|
||||
|
||||
class QrCodeAction implements MiddlewareInterface
|
||||
{
|
||||
@@ -24,41 +24,31 @@ class QrCodeAction implements MiddlewareInterface
|
||||
private const MAX_SIZE = 1000;
|
||||
|
||||
private RouterInterface $router;
|
||||
private UrlShortenerInterface $urlShortener;
|
||||
private ShortUrlResolverInterface $urlResolver;
|
||||
private LoggerInterface $logger;
|
||||
|
||||
public function __construct(
|
||||
RouterInterface $router,
|
||||
UrlShortenerInterface $urlShortener,
|
||||
ShortUrlResolverInterface $urlResolver,
|
||||
?LoggerInterface $logger = null
|
||||
) {
|
||||
$this->router = $router;
|
||||
$this->urlShortener = $urlShortener;
|
||||
$this->urlResolver = $urlResolver;
|
||||
$this->logger = $logger ?: new NullLogger();
|
||||
}
|
||||
|
||||
/**
|
||||
* Process an incoming server request and return a response, optionally delegating
|
||||
* to the next middleware component to create the response.
|
||||
*
|
||||
*
|
||||
* @throws \InvalidArgumentException
|
||||
* @throws RuntimeException
|
||||
*/
|
||||
public function process(Request $request, RequestHandlerInterface $handler): Response
|
||||
{
|
||||
// Make sure the short URL exists for this short code
|
||||
$shortCode = $request->getAttribute('shortCode');
|
||||
$domain = $request->getUri()->getAuthority();
|
||||
$identifier = ShortUrlIdentifier::fromRedirectRequest($request);
|
||||
|
||||
try {
|
||||
$this->urlShortener->shortCodeToUrl($shortCode, $domain);
|
||||
$this->urlResolver->resolveEnabledShortUrl($identifier);
|
||||
} catch (ShortUrlNotFoundException $e) {
|
||||
$this->logger->warning('An error occurred while creating QR code. {e}', ['e' => $e]);
|
||||
return $handler->handle($request);
|
||||
}
|
||||
|
||||
$path = $this->router->generateUri(RedirectAction::class, ['shortCode' => $shortCode]);
|
||||
$path = $this->router->generateUri(RedirectAction::class, ['shortCode' => $identifier->shortCode()]);
|
||||
$size = $this->getSizeParam($request);
|
||||
|
||||
$qrCode = new QrCode((string) $request->getUri()->withPath($path)->withQuery(''));
|
||||
|
||||
@@ -4,9 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Entity;
|
||||
|
||||
use JsonSerializable;
|
||||
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
|
||||
|
||||
class Domain extends AbstractEntity
|
||||
class Domain extends AbstractEntity implements JsonSerializable
|
||||
{
|
||||
private string $authority;
|
||||
|
||||
@@ -19,4 +20,9 @@ class Domain extends AbstractEntity
|
||||
{
|
||||
return $this->authority;
|
||||
}
|
||||
|
||||
public function jsonSerialize(): string
|
||||
{
|
||||
return $this->getAuthority();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,11 @@ class ShortUrl extends AbstractEntity
|
||||
return $this->dateCreated;
|
||||
}
|
||||
|
||||
public function getDomain(): ?Domain
|
||||
{
|
||||
return $this->domain;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection|Tag[]
|
||||
*/
|
||||
@@ -135,7 +140,6 @@ class ShortUrl extends AbstractEntity
|
||||
|
||||
/**
|
||||
* @param Collection|Visit[] $visits
|
||||
* @return ShortUrl
|
||||
* @internal
|
||||
*/
|
||||
public function setVisits(Collection $visits): self
|
||||
@@ -149,9 +153,25 @@ class ShortUrl extends AbstractEntity
|
||||
return $this->maxVisits;
|
||||
}
|
||||
|
||||
public function maxVisitsReached(): bool
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->maxVisits !== null && $this->getVisitsCount() >= $this->maxVisits;
|
||||
$maxVisitsReached = $this->maxVisits !== null && $this->getVisitsCount() >= $this->maxVisits;
|
||||
if ($maxVisitsReached) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$now = Chronos::now();
|
||||
$beforeValidSince = $this->validSince !== null && $this->validSince->gt($now);
|
||||
if ($beforeValidSince) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$afterValidUntil = $this->validUntil !== null && $this->validUntil->lt($now);
|
||||
if ($afterValidUntil) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function toString(array $domainConfig): string
|
||||
@@ -186,12 +206,10 @@ class ShortUrl extends AbstractEntity
|
||||
}
|
||||
|
||||
$shortUrlTags = invoke($this->getTags(), '__toString');
|
||||
$hasAllTags = count($shortUrlTags) === count($tags) && array_reduce(
|
||||
return count($shortUrlTags) === count($tags) && array_reduce(
|
||||
$tags,
|
||||
fn (bool $hasAllTags, string $tag) => $hasAllTags && contains($shortUrlTags, $tag),
|
||||
true,
|
||||
);
|
||||
|
||||
return $hasAllTags;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\Core\Exception;
|
||||
use Fig\Http\Message\StatusCodeInterface;
|
||||
use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait;
|
||||
use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
@@ -17,8 +18,10 @@ class ShortUrlNotFoundException extends DomainException implements ProblemDetail
|
||||
private const TITLE = 'Short URL not found';
|
||||
private const TYPE = 'INVALID_SHORTCODE';
|
||||
|
||||
public static function fromNotFoundShortCode(string $shortCode, ?string $domain = null): self
|
||||
public static function fromNotFound(ShortUrlIdentifier $identifier): self
|
||||
{
|
||||
$shortCode = $identifier->shortCode();
|
||||
$domain = $identifier->domain();
|
||||
$suffix = $domain === null ? '' : sprintf(' for domain "%s"', $domain);
|
||||
$e = new self(sprintf('No URL found with short code "%s"%s', $shortCode, $suffix));
|
||||
|
||||
|
||||
54
module/Core/src/Model/ShortUrlIdentifier.php
Normal file
54
module/Core/src/Model/ShortUrlIdentifier.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Model;
|
||||
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
|
||||
final class ShortUrlIdentifier
|
||||
{
|
||||
private string $shortCode;
|
||||
private ?string $domain;
|
||||
|
||||
public function __construct(string $shortCode, ?string $domain = null)
|
||||
{
|
||||
$this->shortCode = $shortCode;
|
||||
$this->domain = $domain;
|
||||
}
|
||||
|
||||
public static function fromApiRequest(ServerRequestInterface $request): self
|
||||
{
|
||||
$shortCode = $request->getAttribute('shortCode', '');
|
||||
$domain = $request->getQueryParams()['domain'] ?? null;
|
||||
|
||||
return new self($shortCode, $domain);
|
||||
}
|
||||
|
||||
public static function fromRedirectRequest(ServerRequestInterface $request): self
|
||||
{
|
||||
$shortCode = $request->getAttribute('shortCode', '');
|
||||
$domain = $request->getUri()->getAuthority();
|
||||
|
||||
return new self($shortCode, $domain);
|
||||
}
|
||||
|
||||
public static function fromCli(InputInterface $input): self
|
||||
{
|
||||
$shortCode = $input->getArguments()['shortCode'] ?? '';
|
||||
$domain = $input->getOptions()['domain'] ?? null;
|
||||
|
||||
return new self($shortCode, $domain);
|
||||
}
|
||||
|
||||
public function shortCode(): string
|
||||
{
|
||||
return $this->shortCode;
|
||||
}
|
||||
|
||||
public function domain(): ?string
|
||||
{
|
||||
return $this->domain;
|
||||
}
|
||||
}
|
||||
@@ -5,15 +5,20 @@ declare(strict_types=1);
|
||||
namespace Shlinkio\Shlink\Core\Model;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use DateTimeInterface;
|
||||
use Shlinkio\Shlink\Core\Exception\ValidationException;
|
||||
use Shlinkio\Shlink\Core\Validation\ShortUrlMetaInputFilter;
|
||||
|
||||
use function array_key_exists;
|
||||
use function Shlinkio\Shlink\Core\parseDateField;
|
||||
|
||||
final class ShortUrlMeta
|
||||
{
|
||||
private bool $validSincePropWasProvided = false;
|
||||
private ?Chronos $validSince = null;
|
||||
private bool $validUntilPropWasProvided = false;
|
||||
private ?Chronos $validUntil = null;
|
||||
private ?string $customSlug = null;
|
||||
private bool $maxVisitsPropWasProvided = false;
|
||||
private ?int $maxVisits = null;
|
||||
private ?bool $findIfExists = null;
|
||||
private ?string $domain = null;
|
||||
@@ -29,83 +34,37 @@ final class ShortUrlMeta
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $data
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public static function createFromRawData(array $data): self
|
||||
public static function fromRawData(array $data): self
|
||||
{
|
||||
$instance = new self();
|
||||
$instance->validate($data);
|
||||
$instance->validateAndInit($data);
|
||||
return $instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|Chronos|null $validSince
|
||||
* @param string|Chronos|null $validUntil
|
||||
* @param string|null $customSlug
|
||||
* @param int|null $maxVisits
|
||||
* @param bool|null $findIfExists
|
||||
* @param string|null $domain
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public static function createFromParams( // phpcs:ignore
|
||||
$validSince = null,
|
||||
$validUntil = null,
|
||||
$customSlug = null,
|
||||
$maxVisits = null,
|
||||
$findIfExists = null,
|
||||
$domain = null
|
||||
): self {
|
||||
// We do not type hint the arguments because that will be done by the validation process and we would get a
|
||||
// type error if any of them do not match
|
||||
$instance = new self();
|
||||
$instance->validate([
|
||||
ShortUrlMetaInputFilter::VALID_SINCE => $validSince,
|
||||
ShortUrlMetaInputFilter::VALID_UNTIL => $validUntil,
|
||||
ShortUrlMetaInputFilter::CUSTOM_SLUG => $customSlug,
|
||||
ShortUrlMetaInputFilter::MAX_VISITS => $maxVisits,
|
||||
ShortUrlMetaInputFilter::FIND_IF_EXISTS => $findIfExists,
|
||||
ShortUrlMetaInputFilter::DOMAIN => $domain,
|
||||
]);
|
||||
return $instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $data
|
||||
* @throws ValidationException
|
||||
*/
|
||||
private function validate(array $data): void
|
||||
private function validateAndInit(array $data): void
|
||||
{
|
||||
$inputFilter = new ShortUrlMetaInputFilter($data);
|
||||
if (! $inputFilter->isValid()) {
|
||||
throw ValidationException::fromInputFilter($inputFilter);
|
||||
}
|
||||
|
||||
$this->validSince = $this->parseDateField($inputFilter->getValue(ShortUrlMetaInputFilter::VALID_SINCE));
|
||||
$this->validUntil = $this->parseDateField($inputFilter->getValue(ShortUrlMetaInputFilter::VALID_UNTIL));
|
||||
$this->validSince = parseDateField($inputFilter->getValue(ShortUrlMetaInputFilter::VALID_SINCE));
|
||||
$this->validSincePropWasProvided = array_key_exists(ShortUrlMetaInputFilter::VALID_SINCE, $data);
|
||||
$this->validUntil = parseDateField($inputFilter->getValue(ShortUrlMetaInputFilter::VALID_UNTIL));
|
||||
$this->validUntilPropWasProvided = array_key_exists(ShortUrlMetaInputFilter::VALID_UNTIL, $data);
|
||||
$this->customSlug = $inputFilter->getValue(ShortUrlMetaInputFilter::CUSTOM_SLUG);
|
||||
$maxVisits = $inputFilter->getValue(ShortUrlMetaInputFilter::MAX_VISITS);
|
||||
$this->maxVisits = $maxVisits !== null ? (int) $maxVisits : null;
|
||||
$this->maxVisitsPropWasProvided = array_key_exists(ShortUrlMetaInputFilter::MAX_VISITS, $data);
|
||||
$this->findIfExists = $inputFilter->getValue(ShortUrlMetaInputFilter::FIND_IF_EXISTS);
|
||||
$this->domain = $inputFilter->getValue(ShortUrlMetaInputFilter::DOMAIN);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|DateTimeInterface|Chronos|null $date
|
||||
*/
|
||||
private function parseDateField($date): ?Chronos
|
||||
{
|
||||
if ($date === null || $date instanceof Chronos) {
|
||||
return $date;
|
||||
}
|
||||
|
||||
if ($date instanceof DateTimeInterface) {
|
||||
return Chronos::instance($date);
|
||||
}
|
||||
|
||||
return Chronos::parse($date);
|
||||
}
|
||||
|
||||
public function getValidSince(): ?Chronos
|
||||
{
|
||||
return $this->validSince;
|
||||
@@ -113,7 +72,7 @@ final class ShortUrlMeta
|
||||
|
||||
public function hasValidSince(): bool
|
||||
{
|
||||
return $this->validSince !== null;
|
||||
return $this->validSincePropWasProvided;
|
||||
}
|
||||
|
||||
public function getValidUntil(): ?Chronos
|
||||
@@ -123,7 +82,7 @@ final class ShortUrlMeta
|
||||
|
||||
public function hasValidUntil(): bool
|
||||
{
|
||||
return $this->validUntil !== null;
|
||||
return $this->validUntilPropWasProvided;
|
||||
}
|
||||
|
||||
public function getCustomSlug(): ?string
|
||||
@@ -143,7 +102,7 @@ final class ShortUrlMeta
|
||||
|
||||
public function hasMaxVisits(): bool
|
||||
{
|
||||
return $this->maxVisits !== null;
|
||||
return $this->maxVisitsPropWasProvided;
|
||||
}
|
||||
|
||||
public function findIfExists(): bool
|
||||
|
||||
68
module/Core/src/Model/ShortUrlsOrdering.php
Normal file
68
module/Core/src/Model/ShortUrlsOrdering.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Model;
|
||||
|
||||
use Shlinkio\Shlink\Core\Exception\ValidationException;
|
||||
|
||||
use function is_array;
|
||||
use function is_string;
|
||||
use function key;
|
||||
|
||||
final class ShortUrlsOrdering
|
||||
{
|
||||
public const ORDER_BY = 'orderBy';
|
||||
private const DEFAULT_ORDER_DIRECTION = 'ASC';
|
||||
|
||||
private ?string $orderField = null;
|
||||
private string $orderDirection = self::DEFAULT_ORDER_DIRECTION;
|
||||
|
||||
/**
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public static function fromRawData(array $query): self
|
||||
{
|
||||
$instance = new self();
|
||||
$instance->validateAndInit($query);
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ValidationException
|
||||
*/
|
||||
private function validateAndInit(array $data): void
|
||||
{
|
||||
/** @var string|array|null $orderBy */
|
||||
$orderBy = $data[self::ORDER_BY] ?? null;
|
||||
if ($orderBy === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$isArray = is_array($orderBy);
|
||||
if (! $isArray && $orderBy !== null && ! is_string($orderBy)) {
|
||||
throw ValidationException::fromArray([
|
||||
'orderBy' => '"Order by" must be an array, string or null',
|
||||
]);
|
||||
}
|
||||
|
||||
$this->orderField = $isArray ? key($orderBy) : $orderBy;
|
||||
$this->orderDirection = $isArray ? $orderBy[$this->orderField] : self::DEFAULT_ORDER_DIRECTION;
|
||||
}
|
||||
|
||||
public function orderField(): ?string
|
||||
{
|
||||
return $this->orderField;
|
||||
}
|
||||
|
||||
public function orderDirection(): string
|
||||
{
|
||||
return $this->orderDirection;
|
||||
}
|
||||
|
||||
public function hasOrderField(): bool
|
||||
{
|
||||
return $this->orderField !== null;
|
||||
}
|
||||
}
|
||||
85
module/Core/src/Model/ShortUrlsParams.php
Normal file
85
module/Core/src/Model/ShortUrlsParams.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Model;
|
||||
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Core\Exception\ValidationException;
|
||||
use Shlinkio\Shlink\Core\Validation\ShortUrlsParamsInputFilter;
|
||||
|
||||
use function Shlinkio\Shlink\Core\parseDateField;
|
||||
|
||||
final class ShortUrlsParams
|
||||
{
|
||||
private int $page;
|
||||
private ?string $searchTerm;
|
||||
private array $tags;
|
||||
private ShortUrlsOrdering $orderBy;
|
||||
private ?DateRange $dateRange;
|
||||
|
||||
private function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
public static function emptyInstance(): self
|
||||
{
|
||||
return self::fromRawData([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public static function fromRawData(array $query): self
|
||||
{
|
||||
$instance = new self();
|
||||
$instance->validateAndInit($query);
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ValidationException
|
||||
*/
|
||||
private function validateAndInit(array $query): void
|
||||
{
|
||||
$inputFilter = new ShortUrlsParamsInputFilter($query);
|
||||
if (! $inputFilter->isValid()) {
|
||||
throw ValidationException::fromInputFilter($inputFilter);
|
||||
}
|
||||
|
||||
$this->page = (int) ($inputFilter->getValue(ShortUrlsParamsInputFilter::PAGE) ?? 1);
|
||||
$this->searchTerm = $inputFilter->getValue(ShortUrlsParamsInputFilter::SEARCH_TERM);
|
||||
$this->tags = (array) $inputFilter->getValue(ShortUrlsParamsInputFilter::TAGS);
|
||||
$this->dateRange = new DateRange(
|
||||
parseDateField($inputFilter->getValue(ShortUrlsParamsInputFilter::START_DATE)),
|
||||
parseDateField($inputFilter->getValue(ShortUrlsParamsInputFilter::END_DATE)),
|
||||
);
|
||||
$this->orderBy = ShortUrlsOrdering::fromRawData($query);
|
||||
}
|
||||
|
||||
public function page(): int
|
||||
{
|
||||
return $this->page;
|
||||
}
|
||||
|
||||
public function searchTerm(): ?string
|
||||
{
|
||||
return $this->searchTerm;
|
||||
}
|
||||
|
||||
public function tags(): array
|
||||
{
|
||||
return $this->tags;
|
||||
}
|
||||
|
||||
public function orderBy(): ShortUrlsOrdering
|
||||
{
|
||||
return $this->orderBy;
|
||||
}
|
||||
|
||||
public function dateRange(): ?DateRange
|
||||
{
|
||||
return $this->dateRange;
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Model;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
|
||||
use function Shlinkio\Shlink\Core\parseDateFromQuery;
|
||||
|
||||
final class VisitsParams
|
||||
{
|
||||
private const FIRST_PAGE = 1;
|
||||
@@ -34,21 +35,13 @@ final class VisitsParams
|
||||
|
||||
public static function fromRawData(array $query): self
|
||||
{
|
||||
$startDate = self::getDateQueryParam($query, 'startDate');
|
||||
$endDate = self::getDateQueryParam($query, 'endDate');
|
||||
|
||||
return new self(
|
||||
new DateRange($startDate, $endDate),
|
||||
new DateRange(parseDateFromQuery($query, 'startDate'), parseDateFromQuery($query, 'endDate')),
|
||||
(int) ($query['page'] ?? 1),
|
||||
isset($query['itemsPerPage']) ? (int) $query['itemsPerPage'] : null,
|
||||
);
|
||||
}
|
||||
|
||||
private static function getDateQueryParam(array $query, string $key): ?Chronos
|
||||
{
|
||||
return ! isset($query[$key]) || empty($query[$key]) ? null : Chronos::parse($query[$key]);
|
||||
}
|
||||
|
||||
public function getDateRange(): DateRange
|
||||
{
|
||||
return $this->dateRange;
|
||||
|
||||
@@ -5,38 +5,20 @@ declare(strict_types=1);
|
||||
namespace Shlinkio\Shlink\Core\Paginator\Adapter;
|
||||
|
||||
use Laminas\Paginator\Adapter\AdapterInterface;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
|
||||
use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
|
||||
|
||||
use function strip_tags;
|
||||
use function trim;
|
||||
|
||||
class ShortUrlRepositoryAdapter implements AdapterInterface
|
||||
{
|
||||
public const ITEMS_PER_PAGE = 10;
|
||||
|
||||
private ShortUrlRepositoryInterface $repository;
|
||||
private ?string $searchTerm;
|
||||
/** @var null|array|string */
|
||||
private $orderBy;
|
||||
private array $tags;
|
||||
private ?DateRange $dateRange;
|
||||
private ShortUrlsParams $params;
|
||||
|
||||
/**
|
||||
* @param string|array|null $orderBy
|
||||
*/
|
||||
public function __construct(
|
||||
ShortUrlRepositoryInterface $repository,
|
||||
?string $searchTerm = null,
|
||||
array $tags = [],
|
||||
$orderBy = null,
|
||||
?DateRange $dateRange = null
|
||||
) {
|
||||
public function __construct(ShortUrlRepositoryInterface $repository, ShortUrlsParams $params)
|
||||
{
|
||||
$this->repository = $repository;
|
||||
$this->searchTerm = $searchTerm !== null ? trim(strip_tags($searchTerm)) : null;
|
||||
$this->orderBy = $orderBy;
|
||||
$this->tags = $tags;
|
||||
$this->dateRange = $dateRange;
|
||||
$this->params = $params;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,10 +32,10 @@ class ShortUrlRepositoryAdapter implements AdapterInterface
|
||||
return $this->repository->findList(
|
||||
$itemCountPerPage,
|
||||
$offset,
|
||||
$this->searchTerm,
|
||||
$this->tags,
|
||||
$this->orderBy,
|
||||
$this->dateRange,
|
||||
$this->params->searchTerm(),
|
||||
$this->params->tags(),
|
||||
$this->params->orderBy(),
|
||||
$this->params->dateRange(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -68,6 +50,10 @@ class ShortUrlRepositoryAdapter implements AdapterInterface
|
||||
*/
|
||||
public function count(): int
|
||||
{
|
||||
return $this->repository->countList($this->searchTerm, $this->tags, $this->dateRange);
|
||||
return $this->repository->countList(
|
||||
$this->params->searchTerm(),
|
||||
$this->params->tags(),
|
||||
$this->params->dateRange(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,26 +5,31 @@ declare(strict_types=1);
|
||||
namespace Shlinkio\Shlink\Core\Paginator\Adapter;
|
||||
|
||||
use Laminas\Paginator\Adapter\AdapterInterface;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
|
||||
|
||||
class VisitsPaginatorAdapter implements AdapterInterface
|
||||
{
|
||||
private VisitRepositoryInterface $visitRepository;
|
||||
private string $shortCode;
|
||||
private ShortUrlIdentifier $identifier;
|
||||
private VisitsParams $params;
|
||||
|
||||
public function __construct(VisitRepositoryInterface $visitRepository, string $shortCode, VisitsParams $params)
|
||||
{
|
||||
public function __construct(
|
||||
VisitRepositoryInterface $visitRepository,
|
||||
ShortUrlIdentifier $identifier,
|
||||
VisitsParams $params
|
||||
) {
|
||||
$this->visitRepository = $visitRepository;
|
||||
$this->shortCode = $shortCode;
|
||||
$this->params = $params;
|
||||
$this->identifier = $identifier;
|
||||
}
|
||||
|
||||
public function getItems($offset, $itemCountPerPage): array // phpcs:ignore
|
||||
{
|
||||
return $this->visitRepository->findVisitsByShortCode(
|
||||
$this->shortCode,
|
||||
$this->identifier->shortCode(),
|
||||
$this->identifier->domain(),
|
||||
$this->params->getDateRange(),
|
||||
$itemCountPerPage,
|
||||
$offset,
|
||||
@@ -33,6 +38,10 @@ class VisitsPaginatorAdapter implements AdapterInterface
|
||||
|
||||
public function count(): int
|
||||
{
|
||||
return $this->visitRepository->countVisitsByShortCode($this->shortCode, $this->params->getDateRange());
|
||||
return $this->visitRepository->countVisitsByShortCode(
|
||||
$this->identifier->shortCode(),
|
||||
$this->identifier->domain(),
|
||||
$this->params->getDateRange(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,23 +4,20 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Repository;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlsOrdering;
|
||||
|
||||
use function array_column;
|
||||
use function array_key_exists;
|
||||
use function Functional\contains;
|
||||
use function is_array;
|
||||
use function key;
|
||||
|
||||
class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* @param string[] $tags
|
||||
* @param string|array|null $orderBy
|
||||
* @return ShortUrl[]
|
||||
*/
|
||||
public function findList(
|
||||
@@ -28,7 +25,7 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
|
||||
?int $offset = null,
|
||||
?string $searchTerm = null,
|
||||
array $tags = [],
|
||||
$orderBy = null,
|
||||
?ShortUrlsOrdering $orderBy = null,
|
||||
?DateRange $dateRange = null
|
||||
): array {
|
||||
$qb = $this->createListQueryBuilder($searchTerm, $tags, $dateRange);
|
||||
@@ -43,7 +40,7 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
|
||||
}
|
||||
|
||||
// In case the ordering has been specified, the query could be more complex. Process it
|
||||
if ($orderBy !== null) {
|
||||
if ($orderBy !== null && $orderBy->hasOrderField()) {
|
||||
return $this->processOrderByForList($qb, $orderBy);
|
||||
}
|
||||
|
||||
@@ -52,14 +49,10 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|array|null $orderBy
|
||||
*/
|
||||
private function processOrderByForList(QueryBuilder $qb, $orderBy): array
|
||||
private function processOrderByForList(QueryBuilder $qb, ShortUrlsOrdering $orderBy): array
|
||||
{
|
||||
$isArray = is_array($orderBy);
|
||||
$fieldName = $isArray ? key($orderBy) : $orderBy;
|
||||
$order = $isArray ? $orderBy[$fieldName] : 'ASC';
|
||||
$fieldName = $orderBy->orderField();
|
||||
$order = $orderBy->orderDirection();
|
||||
|
||||
if (contains(['visits', 'visitsCount', 'visitCount'], $fieldName)) {
|
||||
$qb->addSelect('COUNT(DISTINCT v) AS totalVisits')
|
||||
@@ -97,8 +90,8 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
|
||||
?DateRange $dateRange = null
|
||||
): QueryBuilder {
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
$qb->from(ShortUrl::class, 's');
|
||||
$qb->where('1=1');
|
||||
$qb->from(ShortUrl::class, 's')
|
||||
->where('1=1');
|
||||
|
||||
if ($dateRange !== null && $dateRange->getStartDate() !== null) {
|
||||
$qb->andWhere($qb->expr()->gte('s.dateCreated', ':startDate'));
|
||||
@@ -117,12 +110,14 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
|
||||
}
|
||||
|
||||
// Apply search conditions
|
||||
$qb->andWhere($qb->expr()->orX(
|
||||
$qb->expr()->like('s.longUrl', ':searchPattern'),
|
||||
$qb->expr()->like('s.shortCode', ':searchPattern'),
|
||||
$qb->expr()->like('t.name', ':searchPattern'),
|
||||
));
|
||||
$qb->setParameter('searchPattern', '%' . $searchTerm . '%');
|
||||
$qb->leftJoin('s.domain', 'd')
|
||||
->andWhere($qb->expr()->orX(
|
||||
$qb->expr()->like('s.longUrl', ':searchPattern'),
|
||||
$qb->expr()->like('s.shortCode', ':searchPattern'),
|
||||
$qb->expr()->like('t.name', ':searchPattern'),
|
||||
$qb->expr()->like('d.authority', ':searchPattern'),
|
||||
))
|
||||
->setParameter('searchPattern', '%' . $searchTerm . '%');
|
||||
}
|
||||
|
||||
// Filter by tags if provided
|
||||
@@ -134,7 +129,7 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
|
||||
return $qb;
|
||||
}
|
||||
|
||||
public function findOneByShortCode(string $shortCode, ?string $domain = null): ?ShortUrl
|
||||
public function findOneWithDomainFallback(string $shortCode, ?string $domain = null): ?ShortUrl
|
||||
{
|
||||
// When ordering DESC, Postgres puts nulls at the beginning while the rest of supported DB engines put them at
|
||||
// the bottom
|
||||
@@ -146,8 +141,6 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
|
||||
FROM Shlinkio\Shlink\Core\Entity\ShortUrl AS s
|
||||
LEFT JOIN s.domain AS d
|
||||
WHERE s.shortCode = :shortCode
|
||||
AND (s.validSince <= :now OR s.validSince IS NULL)
|
||||
AND (s.validUntil >= :now OR s.validUntil IS NULL)
|
||||
AND (s.domain IS NULL OR d.authority = :domain)
|
||||
ORDER BY s.domain {$ordering}
|
||||
DQL;
|
||||
@@ -156,7 +149,6 @@ DQL;
|
||||
$query->setMaxResults(1)
|
||||
->setParameters([
|
||||
'shortCode' => $shortCode,
|
||||
'now' => Chronos::now(),
|
||||
'domain' => $domain,
|
||||
]);
|
||||
|
||||
@@ -166,19 +158,33 @@ DQL;
|
||||
// * The short URL matching the short code but without any domain, or
|
||||
// * No short URL at all
|
||||
|
||||
/** @var ShortUrl|null $shortUrl */
|
||||
$shortUrl = $query->getOneOrNullResult();
|
||||
return $shortUrl !== null && ! $shortUrl->maxVisitsReached() ? $shortUrl : null;
|
||||
return $query->getOneOrNullResult();
|
||||
}
|
||||
|
||||
public function findOne(string $shortCode, ?string $domain = null): ?ShortUrl
|
||||
{
|
||||
$qb = $this->createFindOneQueryBuilder($shortCode, $domain);
|
||||
$qb->select('s');
|
||||
|
||||
return $qb->getQuery()->getOneOrNullResult();
|
||||
}
|
||||
|
||||
public function shortCodeIsInUse(string $slug, ?string $domain = null): bool
|
||||
{
|
||||
$qb = $this->createFindOneQueryBuilder($slug, $domain);
|
||||
$qb->select('COUNT(DISTINCT s.id)');
|
||||
|
||||
return ((int) $qb->getQuery()->getSingleScalarResult()) > 0;
|
||||
}
|
||||
|
||||
private function createFindOneQueryBuilder(string $slug, ?string $domain = null): QueryBuilder
|
||||
{
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
$qb->select('COUNT(DISTINCT s.id)')
|
||||
->from(ShortUrl::class, 's')
|
||||
$qb->from(ShortUrl::class, 's')
|
||||
->where($qb->expr()->isNotNull('s.shortCode'))
|
||||
->andWhere($qb->expr()->eq('s.shortCode', ':slug'))
|
||||
->setParameter('slug', $slug);
|
||||
->setParameter('slug', $slug)
|
||||
->setMaxResults(1);
|
||||
|
||||
if ($domain !== null) {
|
||||
$qb->join('s.domain', 'd')
|
||||
@@ -188,7 +194,6 @@ DQL;
|
||||
$qb->andWhere($qb->expr()->isNull('s.domain'));
|
||||
}
|
||||
|
||||
$result = (int) $qb->getQuery()->getSingleScalarResult();
|
||||
return $result > 0;
|
||||
return $qb;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,24 +7,24 @@ namespace Shlinkio\Shlink\Core\Repository;
|
||||
use Doctrine\Persistence\ObjectRepository;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlsOrdering;
|
||||
|
||||
interface ShortUrlRepositoryInterface extends ObjectRepository
|
||||
{
|
||||
/**
|
||||
* @param string|array|null $orderBy
|
||||
*/
|
||||
public function findList(
|
||||
?int $limit = null,
|
||||
?int $offset = null,
|
||||
?string $searchTerm = null,
|
||||
array $tags = [],
|
||||
$orderBy = null,
|
||||
?ShortUrlsOrdering $orderBy = null,
|
||||
?DateRange $dateRange = null
|
||||
): array;
|
||||
|
||||
public function countList(?string $searchTerm = null, array $tags = [], ?DateRange $dateRange = null): int;
|
||||
|
||||
public function findOneByShortCode(string $shortCode, ?string $domain = null): ?ShortUrl;
|
||||
public function findOneWithDomainFallback(string $shortCode, ?string $domain = null): ?ShortUrl;
|
||||
|
||||
public function findOne(string $shortCode, ?string $domain = null): ?ShortUrl;
|
||||
|
||||
public function shortCodeIsInUse(string $slug, ?string $domain): bool;
|
||||
}
|
||||
|
||||
@@ -46,11 +46,12 @@ DQL;
|
||||
*/
|
||||
public function findVisitsByShortCode(
|
||||
string $shortCode,
|
||||
?string $domain = null,
|
||||
?DateRange $dateRange = null,
|
||||
?int $limit = null,
|
||||
?int $offset = null
|
||||
): array {
|
||||
$qb = $this->createVisitsByShortCodeQueryBuilder($shortCode, $dateRange);
|
||||
$qb = $this->createVisitsByShortCodeQueryBuilder($shortCode, $domain, $dateRange);
|
||||
$qb->select('v')
|
||||
->orderBy('v.date', 'DESC');
|
||||
|
||||
@@ -64,22 +65,34 @@ DQL;
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
public function countVisitsByShortCode(string $shortCode, ?DateRange $dateRange = null): int
|
||||
public function countVisitsByShortCode(string $shortCode, ?string $domain = null, ?DateRange $dateRange = null): int
|
||||
{
|
||||
$qb = $this->createVisitsByShortCodeQueryBuilder($shortCode, $dateRange);
|
||||
$qb = $this->createVisitsByShortCodeQueryBuilder($shortCode, $domain, $dateRange);
|
||||
$qb->select('COUNT(DISTINCT v.id)');
|
||||
|
||||
return (int) $qb->getQuery()->getSingleScalarResult();
|
||||
}
|
||||
|
||||
private function createVisitsByShortCodeQueryBuilder(string $shortCode, ?DateRange $dateRange = null): QueryBuilder
|
||||
{
|
||||
private function createVisitsByShortCodeQueryBuilder(
|
||||
string $shortCode,
|
||||
?string $domain,
|
||||
?DateRange $dateRange
|
||||
): QueryBuilder {
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
$qb->from(Visit::class, 'v')
|
||||
->join('v.shortUrl', 'su')
|
||||
->where($qb->expr()->eq('su.shortCode', ':shortCode'))
|
||||
->setParameter('shortCode', $shortCode);
|
||||
|
||||
// Apply domain filtering
|
||||
if ($domain !== null) {
|
||||
$qb->join('su.domain', 'd')
|
||||
->andWhere($qb->expr()->eq('d.authority', ':domain'))
|
||||
->setParameter('domain', $domain);
|
||||
} else {
|
||||
$qb->andWhere($qb->expr()->isNull('su.domain'));
|
||||
}
|
||||
|
||||
// Apply date range filtering
|
||||
if ($dateRange !== null && $dateRange->getStartDate() !== null) {
|
||||
$qb->andWhere($qb->expr()->gte('v.date', ':startDate'))
|
||||
|
||||
@@ -28,10 +28,15 @@ interface VisitRepositoryInterface extends ObjectRepository
|
||||
*/
|
||||
public function findVisitsByShortCode(
|
||||
string $shortCode,
|
||||
?string $domain = null,
|
||||
?DateRange $dateRange = null,
|
||||
?int $limit = null,
|
||||
?int $offset = null
|
||||
): array;
|
||||
|
||||
public function countVisitsByShortCode(string $shortCode, ?DateRange $dateRange = null): int;
|
||||
public function countVisitsByShortCode(
|
||||
string $shortCode,
|
||||
?string $domain = null,
|
||||
?DateRange $dateRange = null
|
||||
): int;
|
||||
}
|
||||
|
||||
@@ -7,28 +7,32 @@ namespace Shlinkio\Shlink\Core\Service\ShortUrl;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Exception;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Options\DeleteShortUrlsOptions;
|
||||
|
||||
class DeleteShortUrlService implements DeleteShortUrlServiceInterface
|
||||
{
|
||||
use FindShortCodeTrait;
|
||||
|
||||
private EntityManagerInterface $em;
|
||||
private DeleteShortUrlsOptions $deleteShortUrlsOptions;
|
||||
private ShortUrlResolverInterface $urlResolver;
|
||||
|
||||
public function __construct(EntityManagerInterface $em, DeleteShortUrlsOptions $deleteShortUrlsOptions)
|
||||
{
|
||||
public function __construct(
|
||||
EntityManagerInterface $em,
|
||||
DeleteShortUrlsOptions $deleteShortUrlsOptions,
|
||||
ShortUrlResolverInterface $urlResolver
|
||||
) {
|
||||
$this->em = $em;
|
||||
$this->deleteShortUrlsOptions = $deleteShortUrlsOptions;
|
||||
$this->urlResolver = $urlResolver;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception\ShortUrlNotFoundException
|
||||
* @throws Exception\DeleteShortUrlException
|
||||
*/
|
||||
public function deleteByShortCode(string $shortCode, bool $ignoreThreshold = false): void
|
||||
public function deleteByShortCode(ShortUrlIdentifier $identifier, bool $ignoreThreshold = false): void
|
||||
{
|
||||
$shortUrl = $this->findByShortCode($this->em, $shortCode);
|
||||
$shortUrl = $this->urlResolver->resolveShortUrl($identifier);
|
||||
if (! $ignoreThreshold && $this->isThresholdReached($shortUrl)) {
|
||||
throw Exception\DeleteShortUrlException::fromVisitsThreshold(
|
||||
$this->deleteShortUrlsOptions->getVisitsThreshold(),
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace Shlinkio\Shlink\Core\Service\ShortUrl;
|
||||
|
||||
use Shlinkio\Shlink\Core\Exception;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
|
||||
interface DeleteShortUrlServiceInterface
|
||||
{
|
||||
@@ -12,5 +13,5 @@ interface DeleteShortUrlServiceInterface
|
||||
* @throws Exception\ShortUrlNotFoundException
|
||||
* @throws Exception\DeleteShortUrlException
|
||||
*/
|
||||
public function deleteByShortCode(string $shortCode, bool $ignoreThreshold = false): void;
|
||||
public function deleteByShortCode(ShortUrlIdentifier $identifier, bool $ignoreThreshold = false): void;
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Service\ShortUrl;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||
|
||||
trait FindShortCodeTrait
|
||||
{
|
||||
/**
|
||||
* @throws ShortUrlNotFoundException
|
||||
*/
|
||||
private function findByShortCode(EntityManagerInterface $em, string $shortCode): ShortUrl
|
||||
{
|
||||
/** @var ShortUrl|null $shortUrl */
|
||||
$shortUrl = $em->getRepository(ShortUrl::class)->findOneBy([
|
||||
'shortCode' => $shortCode,
|
||||
]);
|
||||
if ($shortUrl === null) {
|
||||
throw ShortUrlNotFoundException::fromNotFoundShortCode($shortCode);
|
||||
}
|
||||
|
||||
return $shortUrl;
|
||||
}
|
||||
}
|
||||
51
module/Core/src/Service/ShortUrl/ShortUrlResolver.php
Normal file
51
module/Core/src/Service/ShortUrl/ShortUrlResolver.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Service\ShortUrl;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
|
||||
|
||||
class ShortUrlResolver implements ShortUrlResolverInterface
|
||||
{
|
||||
private EntityManagerInterface $em;
|
||||
|
||||
public function __construct(EntityManagerInterface $em)
|
||||
{
|
||||
$this->em = $em;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ShortUrlNotFoundException
|
||||
*/
|
||||
public function resolveShortUrl(ShortUrlIdentifier $identifier): ShortUrl
|
||||
{
|
||||
/** @var ShortUrlRepository $shortUrlRepo */
|
||||
$shortUrlRepo = $this->em->getRepository(ShortUrl::class);
|
||||
$shortUrl = $shortUrlRepo->findOne($identifier->shortCode(), $identifier->domain());
|
||||
if ($shortUrl === null) {
|
||||
throw ShortUrlNotFoundException::fromNotFound($identifier);
|
||||
}
|
||||
|
||||
return $shortUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ShortUrlNotFoundException
|
||||
*/
|
||||
public function resolveEnabledShortUrl(ShortUrlIdentifier $identifier): ShortUrl
|
||||
{
|
||||
/** @var ShortUrlRepository $shortUrlRepo */
|
||||
$shortUrlRepo = $this->em->getRepository(ShortUrl::class);
|
||||
$shortUrl = $shortUrlRepo->findOneWithDomainFallback($identifier->shortCode(), $identifier->domain());
|
||||
if ($shortUrl === null || ! $shortUrl->isEnabled()) {
|
||||
throw ShortUrlNotFoundException::fromNotFound($identifier);
|
||||
}
|
||||
|
||||
return $shortUrl;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Service\ShortUrl;
|
||||
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
|
||||
interface ShortUrlResolverInterface
|
||||
{
|
||||
/**
|
||||
* @throws ShortUrlNotFoundException
|
||||
*/
|
||||
public function resolveShortUrl(ShortUrlIdentifier $identifier): ShortUrl;
|
||||
|
||||
/**
|
||||
* @throws ShortUrlNotFoundException
|
||||
*/
|
||||
public function resolveEnabledShortUrl(ShortUrlIdentifier $identifier): ShortUrl;
|
||||
}
|
||||
@@ -6,45 +6,39 @@ namespace Shlinkio\Shlink\Core\Service;
|
||||
|
||||
use Doctrine\ORM;
|
||||
use Laminas\Paginator\Paginator;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
|
||||
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\FindShortCodeTrait;
|
||||
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
|
||||
use Shlinkio\Shlink\Core\Util\TagManagerTrait;
|
||||
|
||||
class ShortUrlService implements ShortUrlServiceInterface
|
||||
{
|
||||
use FindShortCodeTrait;
|
||||
use TagManagerTrait;
|
||||
|
||||
private ORM\EntityManagerInterface $em;
|
||||
private ShortUrlResolverInterface $urlResolver;
|
||||
|
||||
public function __construct(ORM\EntityManagerInterface $em)
|
||||
public function __construct(ORM\EntityManagerInterface $em, ShortUrlResolverInterface $urlResolver)
|
||||
{
|
||||
$this->em = $em;
|
||||
$this->urlResolver = $urlResolver;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $tags
|
||||
* @param array|string|null $orderBy
|
||||
*
|
||||
* @return ShortUrl[]|Paginator
|
||||
*/
|
||||
public function listShortUrls(
|
||||
int $page = 1,
|
||||
?string $searchQuery = null,
|
||||
array $tags = [],
|
||||
$orderBy = null,
|
||||
?DateRange $dateRange = null
|
||||
) {
|
||||
public function listShortUrls(ShortUrlsParams $params): Paginator
|
||||
{
|
||||
/** @var ShortUrlRepository $repo */
|
||||
$repo = $this->em->getRepository(ShortUrl::class);
|
||||
$paginator = new Paginator(new ShortUrlRepositoryAdapter($repo, $searchQuery, $tags, $orderBy, $dateRange));
|
||||
$paginator = new Paginator(new ShortUrlRepositoryAdapter($repo, $params));
|
||||
$paginator->setItemCountPerPage(ShortUrlRepositoryAdapter::ITEMS_PER_PAGE)
|
||||
->setCurrentPageNumber($page);
|
||||
->setCurrentPageNumber($params->page());
|
||||
|
||||
return $paginator;
|
||||
}
|
||||
@@ -53,10 +47,11 @@ class ShortUrlService implements ShortUrlServiceInterface
|
||||
* @param string[] $tags
|
||||
* @throws ShortUrlNotFoundException
|
||||
*/
|
||||
public function setTagsByShortCode(string $shortCode, array $tags = []): ShortUrl
|
||||
public function setTagsByShortCode(ShortUrlIdentifier $identifier, array $tags = []): ShortUrl
|
||||
{
|
||||
$shortUrl = $this->findByShortCode($this->em, $shortCode);
|
||||
$shortUrl = $this->urlResolver->resolveShortUrl($identifier);
|
||||
$shortUrl->setTags($this->tagNamesToEntities($this->em, $tags));
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
return $shortUrl;
|
||||
@@ -65,9 +60,9 @@ class ShortUrlService implements ShortUrlServiceInterface
|
||||
/**
|
||||
* @throws ShortUrlNotFoundException
|
||||
*/
|
||||
public function updateMetadataByShortCode(string $shortCode, ShortUrlMeta $shortUrlMeta): ShortUrl
|
||||
public function updateMetadataByShortCode(ShortUrlIdentifier $identifier, ShortUrlMeta $shortUrlMeta): ShortUrl
|
||||
{
|
||||
$shortUrl = $this->findByShortCode($this->em, $shortCode);
|
||||
$shortUrl = $this->urlResolver->resolveShortUrl($identifier);
|
||||
$shortUrl->updateMeta($shortUrlMeta);
|
||||
|
||||
$this->em->flush();
|
||||
|
||||
@@ -5,35 +5,27 @@ declare(strict_types=1);
|
||||
namespace Shlinkio\Shlink\Core\Service;
|
||||
|
||||
use Laminas\Paginator\Paginator;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
|
||||
|
||||
interface ShortUrlServiceInterface
|
||||
{
|
||||
/**
|
||||
* @param string[] $tags
|
||||
* @param array|string|null $orderBy
|
||||
*
|
||||
* @return ShortUrl[]|Paginator
|
||||
*/
|
||||
public function listShortUrls(
|
||||
int $page = 1,
|
||||
?string $searchQuery = null,
|
||||
array $tags = [],
|
||||
$orderBy = null,
|
||||
?DateRange $dateRange = null
|
||||
);
|
||||
public function listShortUrls(ShortUrlsParams $params): Paginator;
|
||||
|
||||
/**
|
||||
* @param string[] $tags
|
||||
* @throws ShortUrlNotFoundException
|
||||
*/
|
||||
public function setTagsByShortCode(string $shortCode, array $tags = []): ShortUrl;
|
||||
public function setTagsByShortCode(ShortUrlIdentifier $identifier, array $tags = []): ShortUrl;
|
||||
|
||||
/**
|
||||
* @throws ShortUrlNotFoundException
|
||||
*/
|
||||
public function updateMetadataByShortCode(string $shortCode, ShortUrlMeta $shortUrlMeta): ShortUrl;
|
||||
public function updateMetadataByShortCode(ShortUrlIdentifier $identifier, ShortUrlMeta $shortUrlMeta): ShortUrl;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ use Shlinkio\Shlink\Core\Domain\Resolver\PersistenceDomainResolver;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
|
||||
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
|
||||
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
|
||||
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
|
||||
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
|
||||
@@ -124,20 +123,4 @@ class UrlShortener implements UrlShortenerInterface
|
||||
$this->verifyShortCodeUniqueness($meta, $shortUrlToBeCreated);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ShortUrlNotFoundException
|
||||
* @fixme Move this method to a different service
|
||||
*/
|
||||
public function shortCodeToUrl(string $shortCode, ?string $domain = null): ShortUrl
|
||||
{
|
||||
/** @var ShortUrlRepository $shortUrlRepo */
|
||||
$shortUrlRepo = $this->em->getRepository(ShortUrl::class);
|
||||
$shortUrl = $shortUrlRepo->findOneByShortCode($shortCode, $domain);
|
||||
if ($shortUrl === null) {
|
||||
throw ShortUrlNotFoundException::fromNotFoundShortCode($shortCode, $domain);
|
||||
}
|
||||
|
||||
return $shortUrl;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ use Psr\Http\Message\UriInterface;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
|
||||
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
|
||||
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
|
||||
|
||||
interface UrlShortenerInterface
|
||||
@@ -19,9 +18,4 @@ interface UrlShortenerInterface
|
||||
* @throws InvalidUrlException
|
||||
*/
|
||||
public function urlToShortCode(UriInterface $url, array $tags, ShortUrlMeta $meta): ShortUrl;
|
||||
|
||||
/**
|
||||
* @throws ShortUrlNotFoundException
|
||||
*/
|
||||
public function shortCodeToUrl(string $shortCode, ?string $domain = null): ShortUrl;
|
||||
}
|
||||
|
||||
@@ -11,9 +11,11 @@ use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\EventDispatcher\ShortUrlVisited;
|
||||
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Paginator\Adapter\VisitsPaginatorAdapter;
|
||||
use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
|
||||
use Shlinkio\Shlink\Core\Repository\VisitRepository;
|
||||
|
||||
class VisitsTracker implements VisitsTrackerInterface
|
||||
@@ -30,13 +32,8 @@ class VisitsTracker implements VisitsTrackerInterface
|
||||
/**
|
||||
* Tracks a new visit to provided short code from provided visitor
|
||||
*/
|
||||
public function track(string $shortCode, Visitor $visitor): void
|
||||
public function track(ShortUrl $shortUrl, Visitor $visitor): void
|
||||
{
|
||||
/** @var ShortUrl $shortUrl */
|
||||
$shortUrl = $this->em->getRepository(ShortUrl::class)->findOneBy([
|
||||
'shortCode' => $shortCode,
|
||||
]);
|
||||
|
||||
$visit = new Visit($shortUrl, $visitor);
|
||||
|
||||
$this->em->persist($visit);
|
||||
@@ -51,17 +48,17 @@ class VisitsTracker implements VisitsTrackerInterface
|
||||
* @return Visit[]|Paginator
|
||||
* @throws ShortUrlNotFoundException
|
||||
*/
|
||||
public function info(string $shortCode, VisitsParams $params): Paginator
|
||||
public function info(ShortUrlIdentifier $identifier, VisitsParams $params): Paginator
|
||||
{
|
||||
/** @var ORM\EntityRepository $repo */
|
||||
/** @var ShortUrlRepositoryInterface $repo */
|
||||
$repo = $this->em->getRepository(ShortUrl::class);
|
||||
if ($repo->count(['shortCode' => $shortCode]) < 1) {
|
||||
throw ShortUrlNotFoundException::fromNotFoundShortCode($shortCode);
|
||||
if (! $repo->shortCodeIsInUse($identifier->shortCode(), $identifier->domain())) {
|
||||
throw ShortUrlNotFoundException::fromNotFound($identifier);
|
||||
}
|
||||
|
||||
/** @var VisitRepository $repo */
|
||||
$repo = $this->em->getRepository(Visit::class);
|
||||
$paginator = new Paginator(new VisitsPaginatorAdapter($repo, $shortCode, $params));
|
||||
$paginator = new Paginator(new VisitsPaginatorAdapter($repo, $identifier, $params));
|
||||
$paginator->setItemCountPerPage($params->getItemsPerPage())
|
||||
->setCurrentPageNumber($params->getPage());
|
||||
|
||||
|
||||
@@ -5,8 +5,10 @@ declare(strict_types=1);
|
||||
namespace Shlinkio\Shlink\Core\Service;
|
||||
|
||||
use Laminas\Paginator\Paginator;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||
|
||||
@@ -15,7 +17,7 @@ interface VisitsTrackerInterface
|
||||
/**
|
||||
* Tracks a new visit to provided short code from provided visitor
|
||||
*/
|
||||
public function track(string $shortCode, Visitor $visitor): void;
|
||||
public function track(ShortUrl $shortUrl, Visitor $visitor): void;
|
||||
|
||||
/**
|
||||
* Returns the visits on certain short code
|
||||
@@ -23,5 +25,5 @@ interface VisitsTrackerInterface
|
||||
* @return Visit[]|Paginator
|
||||
* @throws ShortUrlNotFoundException
|
||||
*/
|
||||
public function info(string $shortCode, VisitsParams $params): Paginator;
|
||||
public function info(ShortUrlIdentifier $identifier, VisitsParams $params): Paginator;
|
||||
}
|
||||
|
||||
@@ -24,16 +24,15 @@ class ShortUrlDataTransformer implements DataTransformerInterface
|
||||
*/
|
||||
public function transform($shortUrl): array // phpcs:ignore
|
||||
{
|
||||
$longUrl = $shortUrl->getLongUrl();
|
||||
|
||||
return [
|
||||
'shortCode' => $shortUrl->getShortCode(),
|
||||
'shortUrl' => $shortUrl->toString($this->domainConfig),
|
||||
'longUrl' => $longUrl,
|
||||
'longUrl' => $shortUrl->getLongUrl(),
|
||||
'dateCreated' => $shortUrl->getDateCreated()->toAtomString(),
|
||||
'visitsCount' => $shortUrl->getVisitsCount(),
|
||||
'tags' => invoke($shortUrl->getTags(), '__toString'),
|
||||
'meta' => $this->buildMeta($shortUrl),
|
||||
'domain' => $shortUrl->getDomain(),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -20,12 +20,10 @@ class ShortUrlMetaInputFilter extends InputFilter
|
||||
public const FIND_IF_EXISTS = 'findIfExists';
|
||||
public const DOMAIN = 'domain';
|
||||
|
||||
public function __construct(?array $data = null)
|
||||
public function __construct(array $data)
|
||||
{
|
||||
$this->initialize();
|
||||
if ($data !== null) {
|
||||
$this->setData($data);
|
||||
}
|
||||
$this->setData($data);
|
||||
}
|
||||
|
||||
private function initialize(): void
|
||||
|
||||
45
module/Core/src/Validation/ShortUrlsParamsInputFilter.php
Normal file
45
module/Core/src/Validation/ShortUrlsParamsInputFilter.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Validation;
|
||||
|
||||
use Laminas\Filter;
|
||||
use Laminas\InputFilter\InputFilter;
|
||||
use Laminas\Validator;
|
||||
use Shlinkio\Shlink\Common\Validation;
|
||||
|
||||
class ShortUrlsParamsInputFilter extends InputFilter
|
||||
{
|
||||
use Validation\InputFactoryTrait;
|
||||
|
||||
public const PAGE = 'page';
|
||||
public const SEARCH_TERM = 'searchTerm';
|
||||
public const TAGS = 'tags';
|
||||
public const START_DATE = 'startDate';
|
||||
public const END_DATE = 'endDate';
|
||||
|
||||
public function __construct(array $data)
|
||||
{
|
||||
$this->initialize();
|
||||
$this->setData($data);
|
||||
}
|
||||
|
||||
private function initialize(): void
|
||||
{
|
||||
$this->add($this->createDateInput(self::START_DATE, false));
|
||||
$this->add($this->createDateInput(self::END_DATE, false));
|
||||
|
||||
$this->add($this->createInput(self::SEARCH_TERM, false));
|
||||
|
||||
$page = $this->createInput(self::PAGE, false);
|
||||
$page->getValidatorChain()->attach(new Validator\Digits())
|
||||
->attach(new Validator\GreaterThan(['min' => 1, 'inclusive' => true]));
|
||||
$this->add($page);
|
||||
|
||||
$tags = $this->createArrayInput(self::TAGS, false);
|
||||
$tags->getFilterChain()->attach(new Filter\StringToLower())
|
||||
->attach(new Validation\SluggerFilter());
|
||||
$this->add($tags);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Entity\Tag;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlsOrdering;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
|
||||
use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase;
|
||||
@@ -36,64 +37,42 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function findOneByShortCodeReturnsProperData(): void
|
||||
public function findOneWithDomainFallbackReturnsProperData(): void
|
||||
{
|
||||
$regularOne = new ShortUrl('foo', ShortUrlMeta::createFromParams(null, null, 'foo'));
|
||||
$regularOne = new ShortUrl('foo', ShortUrlMeta::fromRawData(['customSlug' => 'foo']));
|
||||
$this->getEntityManager()->persist($regularOne);
|
||||
|
||||
$notYetValid = new ShortUrl(
|
||||
'bar',
|
||||
ShortUrlMeta::createFromParams(Chronos::now()->addMonth(), null, 'bar_very_long_text'),
|
||||
);
|
||||
$this->getEntityManager()->persist($notYetValid);
|
||||
|
||||
$expired = new ShortUrl('expired', ShortUrlMeta::createFromParams(null, Chronos::now()->subMonth(), 'expired'));
|
||||
$this->getEntityManager()->persist($expired);
|
||||
|
||||
$allVisitsComplete = new ShortUrl('baz', ShortUrlMeta::createFromRawData([
|
||||
'maxVisits' => 3,
|
||||
'customSlug' => 'baz',
|
||||
]));
|
||||
$visits = [];
|
||||
for ($i = 0; $i < 3; $i++) {
|
||||
$visit = new Visit($allVisitsComplete, Visitor::emptyInstance());
|
||||
$this->getEntityManager()->persist($visit);
|
||||
$visits[] = $visit;
|
||||
}
|
||||
$allVisitsComplete->setVisits(new ArrayCollection($visits));
|
||||
$this->getEntityManager()->persist($allVisitsComplete);
|
||||
|
||||
$withDomain = new ShortUrl('foo', ShortUrlMeta::createFromRawData([
|
||||
'domain' => 'example.com',
|
||||
'customSlug' => 'domain-short-code',
|
||||
]));
|
||||
$withDomain = new ShortUrl('foo', ShortUrlMeta::fromRawData(
|
||||
['domain' => 'example.com', 'customSlug' => 'domain-short-code'],
|
||||
));
|
||||
$this->getEntityManager()->persist($withDomain);
|
||||
|
||||
$withDomainDuplicatingRegular = new ShortUrl('foo_with_domain', ShortUrlMeta::createFromRawData([
|
||||
'domain' => 'doma.in',
|
||||
'customSlug' => 'foo',
|
||||
]));
|
||||
$withDomainDuplicatingRegular = new ShortUrl('foo_with_domain', ShortUrlMeta::fromRawData(
|
||||
['domain' => 'doma.in', 'customSlug' => 'foo'],
|
||||
));
|
||||
$this->getEntityManager()->persist($withDomainDuplicatingRegular);
|
||||
|
||||
$this->getEntityManager()->flush();
|
||||
|
||||
$this->assertSame($regularOne, $this->repo->findOneByShortCode($regularOne->getShortCode()));
|
||||
$this->assertSame($regularOne, $this->repo->findOneByShortCode($withDomainDuplicatingRegular->getShortCode()));
|
||||
$this->assertSame($withDomain, $this->repo->findOneByShortCode($withDomain->getShortCode(), 'example.com'));
|
||||
$this->assertSame($regularOne, $this->repo->findOneWithDomainFallback($regularOne->getShortCode()));
|
||||
$this->assertSame($regularOne, $this->repo->findOneWithDomainFallback(
|
||||
$withDomainDuplicatingRegular->getShortCode(),
|
||||
));
|
||||
$this->assertSame($withDomain, $this->repo->findOneWithDomainFallback(
|
||||
$withDomain->getShortCode(),
|
||||
'example.com',
|
||||
));
|
||||
$this->assertSame(
|
||||
$withDomainDuplicatingRegular,
|
||||
$this->repo->findOneByShortCode($withDomainDuplicatingRegular->getShortCode(), 'doma.in'),
|
||||
$this->repo->findOneWithDomainFallback($withDomainDuplicatingRegular->getShortCode(), 'doma.in'),
|
||||
);
|
||||
$this->assertSame(
|
||||
$regularOne,
|
||||
$this->repo->findOneByShortCode($withDomainDuplicatingRegular->getShortCode(), 'other-domain.com'),
|
||||
$this->repo->findOneWithDomainFallback($withDomainDuplicatingRegular->getShortCode(), 'other-domain.com'),
|
||||
);
|
||||
$this->assertNull($this->repo->findOneByShortCode('invalid'));
|
||||
$this->assertNull($this->repo->findOneByShortCode($withDomain->getShortCode()));
|
||||
$this->assertNull($this->repo->findOneByShortCode($withDomain->getShortCode(), 'other-domain.com'));
|
||||
$this->assertNull($this->repo->findOneByShortCode($notYetValid->getShortCode()));
|
||||
$this->assertNull($this->repo->findOneByShortCode($expired->getShortCode()));
|
||||
$this->assertNull($this->repo->findOneByShortCode($allVisitsComplete->getShortCode()));
|
||||
$this->assertNull($this->repo->findOneWithDomainFallback('invalid'));
|
||||
$this->assertNull($this->repo->findOneWithDomainFallback($withDomain->getShortCode()));
|
||||
$this->assertNull($this->repo->findOneWithDomainFallback($withDomain->getShortCode(), 'other-domain.com'));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
@@ -149,7 +128,9 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
|
||||
|
||||
$this->assertCount(1, $this->repo->findList(2, 2));
|
||||
|
||||
$result = $this->repo->findList(null, null, null, [], ['visits' => 'DESC']);
|
||||
$result = $this->repo->findList(null, null, null, [], ShortUrlsOrdering::fromRawData([
|
||||
'orderBy' => ['visits' => 'DESC'],
|
||||
]));
|
||||
$this->assertCount(3, $result);
|
||||
$this->assertSame($bar, $result[0]);
|
||||
|
||||
@@ -175,7 +156,9 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
|
||||
|
||||
$this->getEntityManager()->flush();
|
||||
|
||||
$result = $this->repo->findList(null, null, null, [], ['longUrl' => 'ASC']);
|
||||
$result = $this->repo->findList(null, null, null, [], ShortUrlsOrdering::fromRawData([
|
||||
'orderBy' => ['longUrl' => 'ASC'],
|
||||
]));
|
||||
|
||||
$this->assertCount(count($urls), $result);
|
||||
$this->assertEquals('a', $result[0]->getLongUrl());
|
||||
@@ -187,12 +170,12 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
|
||||
/** @test */
|
||||
public function shortCodeIsInUseLooksForShortUrlInProperSetOfTables(): void
|
||||
{
|
||||
$shortUrlWithoutDomain = new ShortUrl('foo', ShortUrlMeta::createFromRawData(['customSlug' => 'my-cool-slug']));
|
||||
$shortUrlWithoutDomain = new ShortUrl('foo', ShortUrlMeta::fromRawData(['customSlug' => 'my-cool-slug']));
|
||||
$this->getEntityManager()->persist($shortUrlWithoutDomain);
|
||||
|
||||
$shortUrlWithDomain = new ShortUrl(
|
||||
'foo',
|
||||
ShortUrlMeta::createFromRawData(['domain' => 'doma.in', 'customSlug' => 'another-slug']),
|
||||
ShortUrlMeta::fromRawData(['domain' => 'doma.in', 'customSlug' => 'another-slug']),
|
||||
);
|
||||
$this->getEntityManager()->persist($shortUrlWithDomain);
|
||||
|
||||
@@ -205,4 +188,26 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
|
||||
$this->assertFalse($this->repo->shortCodeIsInUse('another-slug', 'example.com'));
|
||||
$this->assertTrue($this->repo->shortCodeIsInUse('another-slug', 'doma.in'));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function findOneLooksForShortUrlInProperSetOfTables(): void
|
||||
{
|
||||
$shortUrlWithoutDomain = new ShortUrl('foo', ShortUrlMeta::fromRawData(['customSlug' => 'my-cool-slug']));
|
||||
$this->getEntityManager()->persist($shortUrlWithoutDomain);
|
||||
|
||||
$shortUrlWithDomain = new ShortUrl(
|
||||
'foo',
|
||||
ShortUrlMeta::fromRawData(['domain' => 'doma.in', 'customSlug' => 'another-slug']),
|
||||
);
|
||||
$this->getEntityManager()->persist($shortUrlWithDomain);
|
||||
|
||||
$this->getEntityManager()->flush();
|
||||
|
||||
$this->assertNotNull($this->repo->findOne('my-cool-slug'));
|
||||
$this->assertNull($this->repo->findOne('my-cool-slug', 'doma.in'));
|
||||
$this->assertNull($this->repo->findOne('slug-not-in-use'));
|
||||
$this->assertNull($this->repo->findOne('another-slug'));
|
||||
$this->assertNull($this->repo->findOne('another-slug', 'example.com'));
|
||||
$this->assertNotNull($this->repo->findOne('another-slug', 'doma.in'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,11 @@ namespace ShlinkioTest\Shlink\Core\Repository;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Core\Entity\Domain;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Entity\VisitLocation;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
use Shlinkio\Shlink\Core\Repository\VisitRepository;
|
||||
use Shlinkio\Shlink\IpGeolocation\Model\Location;
|
||||
@@ -24,6 +26,7 @@ class VisitRepositoryTest extends DatabaseTestCase
|
||||
VisitLocation::class,
|
||||
Visit::class,
|
||||
ShortUrl::class,
|
||||
Domain::class,
|
||||
];
|
||||
|
||||
private VisitRepository $repo;
|
||||
@@ -72,48 +75,73 @@ class VisitRepositoryTest extends DatabaseTestCase
|
||||
/** @test */
|
||||
public function findVisitsByShortCodeReturnsProperData(): void
|
||||
{
|
||||
$shortUrl = new ShortUrl('');
|
||||
$this->getEntityManager()->persist($shortUrl);
|
||||
|
||||
for ($i = 0; $i < 6; $i++) {
|
||||
$visit = new Visit($shortUrl, Visitor::emptyInstance(), Chronos::parse(sprintf('2016-01-0%s', $i + 1)));
|
||||
$this->getEntityManager()->persist($visit);
|
||||
}
|
||||
$this->getEntityManager()->flush();
|
||||
[$shortCode, $domain] = $this->createShortUrlsAndVisits();
|
||||
|
||||
$this->assertCount(0, $this->repo->findVisitsByShortCode('invalid'));
|
||||
$this->assertCount(6, $this->repo->findVisitsByShortCode($shortUrl->getShortCode()));
|
||||
$this->assertCount(2, $this->repo->findVisitsByShortCode($shortUrl->getShortCode(), new DateRange(
|
||||
$this->assertCount(6, $this->repo->findVisitsByShortCode($shortCode));
|
||||
$this->assertCount(3, $this->repo->findVisitsByShortCode($shortCode, $domain));
|
||||
$this->assertCount(2, $this->repo->findVisitsByShortCode($shortCode, null, new DateRange(
|
||||
Chronos::parse('2016-01-02'),
|
||||
Chronos::parse('2016-01-03'),
|
||||
)));
|
||||
$this->assertCount(4, $this->repo->findVisitsByShortCode($shortUrl->getShortCode(), new DateRange(
|
||||
$this->assertCount(4, $this->repo->findVisitsByShortCode($shortCode, null, new DateRange(
|
||||
Chronos::parse('2016-01-03'),
|
||||
)));
|
||||
$this->assertCount(3, $this->repo->findVisitsByShortCode($shortUrl->getShortCode(), null, 3, 2));
|
||||
$this->assertCount(2, $this->repo->findVisitsByShortCode($shortUrl->getShortCode(), null, 5, 4));
|
||||
$this->assertCount(1, $this->repo->findVisitsByShortCode($shortCode, $domain, new DateRange(
|
||||
Chronos::parse('2016-01-03'),
|
||||
)));
|
||||
$this->assertCount(3, $this->repo->findVisitsByShortCode($shortCode, null, null, 3, 2));
|
||||
$this->assertCount(2, $this->repo->findVisitsByShortCode($shortCode, null, null, 5, 4));
|
||||
$this->assertCount(1, $this->repo->findVisitsByShortCode($shortCode, $domain, null, 3, 2));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function countVisitsByShortCodeReturnsProperData(): void
|
||||
{
|
||||
[$shortCode, $domain] = $this->createShortUrlsAndVisits();
|
||||
|
||||
$this->assertEquals(0, $this->repo->countVisitsByShortCode('invalid'));
|
||||
$this->assertEquals(6, $this->repo->countVisitsByShortCode($shortCode));
|
||||
$this->assertEquals(3, $this->repo->countVisitsByShortCode($shortCode, $domain));
|
||||
$this->assertEquals(2, $this->repo->countVisitsByShortCode($shortCode, null, new DateRange(
|
||||
Chronos::parse('2016-01-02'),
|
||||
Chronos::parse('2016-01-03'),
|
||||
)));
|
||||
$this->assertEquals(4, $this->repo->countVisitsByShortCode($shortCode, null, new DateRange(
|
||||
Chronos::parse('2016-01-03'),
|
||||
)));
|
||||
$this->assertEquals(1, $this->repo->countVisitsByShortCode($shortCode, $domain, new DateRange(
|
||||
Chronos::parse('2016-01-03'),
|
||||
)));
|
||||
}
|
||||
|
||||
private function createShortUrlsAndVisits(): array
|
||||
{
|
||||
$shortUrl = new ShortUrl('');
|
||||
$domain = 'example.com';
|
||||
$shortCode = $shortUrl->getShortCode();
|
||||
$shortUrlWithDomain = new ShortUrl('', ShortUrlMeta::fromRawData([
|
||||
'customSlug' => $shortCode,
|
||||
'domain' => $domain,
|
||||
]));
|
||||
|
||||
$this->getEntityManager()->persist($shortUrl);
|
||||
$this->getEntityManager()->persist($shortUrlWithDomain);
|
||||
|
||||
for ($i = 0; $i < 6; $i++) {
|
||||
$visit = new Visit($shortUrl, Visitor::emptyInstance(), Chronos::parse(sprintf('2016-01-0%s', $i + 1)));
|
||||
$this->getEntityManager()->persist($visit);
|
||||
}
|
||||
for ($i = 0; $i < 3; $i++) {
|
||||
$visit = new Visit(
|
||||
$shortUrlWithDomain,
|
||||
Visitor::emptyInstance(),
|
||||
Chronos::parse(sprintf('2016-01-0%s', $i + 1)),
|
||||
);
|
||||
$this->getEntityManager()->persist($visit);
|
||||
}
|
||||
$this->getEntityManager()->flush();
|
||||
|
||||
$this->assertEquals(0, $this->repo->countVisitsByShortCode('invalid'));
|
||||
$this->assertEquals(6, $this->repo->countVisitsByShortCode($shortUrl->getShortCode()));
|
||||
$this->assertEquals(2, $this->repo->countVisitsByShortCode($shortUrl->getShortCode(), new DateRange(
|
||||
Chronos::parse('2016-01-02'),
|
||||
Chronos::parse('2016-01-03'),
|
||||
)));
|
||||
$this->assertEquals(4, $this->repo->countVisitsByShortCode($shortUrl->getShortCode(), new DateRange(
|
||||
Chronos::parse('2016-01-03'),
|
||||
)));
|
||||
return [$shortCode, $domain];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,23 +12,24 @@ use Psr\Http\Server\RequestHandlerInterface;
|
||||
use Shlinkio\Shlink\Common\Response\PixelResponse;
|
||||
use Shlinkio\Shlink\Core\Action\PixelAction;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Options\AppOptions;
|
||||
use Shlinkio\Shlink\Core\Service\UrlShortener;
|
||||
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
|
||||
use Shlinkio\Shlink\Core\Service\VisitsTracker;
|
||||
|
||||
class PixelActionTest extends TestCase
|
||||
{
|
||||
private PixelAction $action;
|
||||
private ObjectProphecy $urlShortener;
|
||||
private ObjectProphecy $urlResolver;
|
||||
private ObjectProphecy $visitTracker;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->urlShortener = $this->prophesize(UrlShortener::class);
|
||||
$this->urlResolver = $this->prophesize(ShortUrlResolverInterface::class);
|
||||
$this->visitTracker = $this->prophesize(VisitsTracker::class);
|
||||
|
||||
$this->action = new PixelAction(
|
||||
$this->urlShortener->reveal(),
|
||||
$this->urlResolver->reveal(),
|
||||
$this->visitTracker->reveal(),
|
||||
new AppOptions(),
|
||||
);
|
||||
@@ -38,7 +39,7 @@ class PixelActionTest extends TestCase
|
||||
public function imageIsReturned(): void
|
||||
{
|
||||
$shortCode = 'abc123';
|
||||
$this->urlShortener->shortCodeToUrl($shortCode, '')->willReturn(
|
||||
$this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($shortCode, ''))->willReturn(
|
||||
new ShortUrl('http://domain.com/foo/bar'),
|
||||
)->shouldBeCalledOnce();
|
||||
$this->visitTracker->track(Argument::cetera())->shouldBeCalledOnce();
|
||||
|
||||
@@ -15,29 +15,31 @@ use Shlinkio\Shlink\Common\Response\QrCodeResponse;
|
||||
use Shlinkio\Shlink\Core\Action\QrCodeAction;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Service\UrlShortener;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
|
||||
|
||||
class QrCodeActionTest extends TestCase
|
||||
{
|
||||
private QrCodeAction $action;
|
||||
private ObjectProphecy $urlShortener;
|
||||
private ObjectProphecy $urlResolver;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$router = $this->prophesize(RouterInterface::class);
|
||||
$router->generateUri(Argument::cetera())->willReturn('/foo/bar');
|
||||
|
||||
$this->urlShortener = $this->prophesize(UrlShortener::class);
|
||||
$this->urlResolver = $this->prophesize(ShortUrlResolverInterface::class);
|
||||
|
||||
$this->action = new QrCodeAction($router->reveal(), $this->urlShortener->reveal());
|
||||
$this->action = new QrCodeAction($router->reveal(), $this->urlResolver->reveal());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function aNotFoundShortCodeWillDelegateIntoNextMiddleware(): void
|
||||
{
|
||||
$shortCode = 'abc123';
|
||||
$this->urlShortener->shortCodeToUrl($shortCode, '')->willThrow(ShortUrlNotFoundException::class)
|
||||
->shouldBeCalledOnce();
|
||||
$this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($shortCode, ''))
|
||||
->willThrow(ShortUrlNotFoundException::class)
|
||||
->shouldBeCalledOnce();
|
||||
$delegate = $this->prophesize(RequestHandlerInterface::class);
|
||||
$process = $delegate->handle(Argument::any())->willReturn(new Response());
|
||||
|
||||
@@ -50,8 +52,9 @@ class QrCodeActionTest extends TestCase
|
||||
public function anInvalidShortCodeWillReturnNotFoundResponse(): void
|
||||
{
|
||||
$shortCode = 'abc123';
|
||||
$this->urlShortener->shortCodeToUrl($shortCode, '')->willThrow(ShortUrlNotFoundException::class)
|
||||
->shouldBeCalledOnce();
|
||||
$this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($shortCode, ''))
|
||||
->willThrow(ShortUrlNotFoundException::class)
|
||||
->shouldBeCalledOnce();
|
||||
$delegate = $this->prophesize(RequestHandlerInterface::class);
|
||||
$process = $delegate->handle(Argument::any())->willReturn(new Response());
|
||||
|
||||
@@ -64,8 +67,9 @@ class QrCodeActionTest extends TestCase
|
||||
public function aCorrectRequestReturnsTheQrCodeResponse(): void
|
||||
{
|
||||
$shortCode = 'abc123';
|
||||
$this->urlShortener->shortCodeToUrl($shortCode, '')->willReturn(new ShortUrl(''))
|
||||
->shouldBeCalledOnce();
|
||||
$this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($shortCode, ''))
|
||||
->willReturn(new ShortUrl(''))
|
||||
->shouldBeCalledOnce();
|
||||
$delegate = $this->prophesize(RequestHandlerInterface::class);
|
||||
|
||||
$resp = $this->action->process(
|
||||
|
||||
@@ -13,25 +13,26 @@ use Psr\Http\Server\RequestHandlerInterface;
|
||||
use Shlinkio\Shlink\Core\Action\RedirectAction;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Options;
|
||||
use Shlinkio\Shlink\Core\Service\UrlShortener;
|
||||
use Shlinkio\Shlink\Core\Service\VisitsTracker;
|
||||
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
|
||||
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
|
||||
|
||||
use function array_key_exists;
|
||||
|
||||
class RedirectActionTest extends TestCase
|
||||
{
|
||||
private RedirectAction $action;
|
||||
private ObjectProphecy $urlShortener;
|
||||
private ObjectProphecy $urlResolver;
|
||||
private ObjectProphecy $visitTracker;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->urlShortener = $this->prophesize(UrlShortener::class);
|
||||
$this->visitTracker = $this->prophesize(VisitsTracker::class);
|
||||
$this->urlResolver = $this->prophesize(ShortUrlResolverInterface::class);
|
||||
$this->visitTracker = $this->prophesize(VisitsTrackerInterface::class);
|
||||
|
||||
$this->action = new RedirectAction(
|
||||
$this->urlShortener->reveal(),
|
||||
$this->urlResolver->reveal(),
|
||||
$this->visitTracker->reveal(),
|
||||
new Options\AppOptions(['disableTrackParam' => 'foobar']),
|
||||
);
|
||||
@@ -45,7 +46,8 @@ class RedirectActionTest extends TestCase
|
||||
{
|
||||
$shortCode = 'abc123';
|
||||
$shortUrl = new ShortUrl('http://domain.com/foo/bar?some=thing');
|
||||
$shortCodeToUrl = $this->urlShortener->shortCodeToUrl($shortCode, '')->willReturn($shortUrl);
|
||||
$shortCodeToUrl = $this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($shortCode, ''))
|
||||
->willReturn($shortUrl);
|
||||
$track = $this->visitTracker->track(Argument::cetera())->will(function (): void {
|
||||
});
|
||||
|
||||
@@ -74,8 +76,9 @@ class RedirectActionTest extends TestCase
|
||||
public function nextMiddlewareIsInvokedIfLongUrlIsNotFound(): void
|
||||
{
|
||||
$shortCode = 'abc123';
|
||||
$this->urlShortener->shortCodeToUrl($shortCode, '')->willThrow(ShortUrlNotFoundException::class)
|
||||
->shouldBeCalledOnce();
|
||||
$this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($shortCode, ''))
|
||||
->willThrow(ShortUrlNotFoundException::class)
|
||||
->shouldBeCalledOnce();
|
||||
$this->visitTracker->track(Argument::cetera())->shouldNotBeCalled();
|
||||
|
||||
$handler = $this->prophesize(RequestHandlerInterface::class);
|
||||
|
||||
@@ -28,7 +28,7 @@ class ShortUrlTest extends TestCase
|
||||
public function provideInvalidShortUrls(): iterable
|
||||
{
|
||||
yield 'with custom slug' => [
|
||||
new ShortUrl('', ShortUrlMeta::createFromRawData(['customSlug' => 'custom-slug'])),
|
||||
new ShortUrl('', ShortUrlMeta::fromRawData(['customSlug' => 'custom-slug'])),
|
||||
'The short code cannot be regenerated on ShortUrls where a custom slug was provided.',
|
||||
];
|
||||
yield 'already persisted' => [
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace ShlinkioTest\Shlink\Core\Exception;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
|
||||
class ShortUrlNotFoundExceptionTest extends TestCase
|
||||
{
|
||||
@@ -23,7 +24,7 @@ class ShortUrlNotFoundExceptionTest extends TestCase
|
||||
$expectedAdditional['domain'] = $domain;
|
||||
}
|
||||
|
||||
$e = ShortUrlNotFoundException::fromNotFoundShortCode($shortCode, $domain);
|
||||
$e = ShortUrlNotFoundException::fromNotFound(new ShortUrlIdentifier($shortCode, $domain));
|
||||
|
||||
$this->assertEquals($expectedMessage, $e->getMessage());
|
||||
$this->assertEquals($expectedMessage, $e->getDetail());
|
||||
|
||||
@@ -21,7 +21,7 @@ class ShortUrlMetaTest extends TestCase
|
||||
public function exceptionIsThrownIfProvidedDataIsInvalid(array $data): void
|
||||
{
|
||||
$this->expectException(ValidationException::class);
|
||||
ShortUrlMeta::createFromRawData($data);
|
||||
ShortUrlMeta::fromRawData($data);
|
||||
}
|
||||
|
||||
public function provideInvalidData(): iterable
|
||||
@@ -49,7 +49,9 @@ class ShortUrlMetaTest extends TestCase
|
||||
/** @test */
|
||||
public function properlyCreatedInstanceReturnsValues(): void
|
||||
{
|
||||
$meta = ShortUrlMeta::createFromParams(Chronos::parse('2015-01-01')->toAtomString(), null, 'foobar');
|
||||
$meta = ShortUrlMeta::fromRawData(
|
||||
['validSince' => Chronos::parse('2015-01-01')->toAtomString(), 'customSlug' => 'foobar'],
|
||||
);
|
||||
|
||||
$this->assertTrue($meta->hasValidSince());
|
||||
$this->assertEquals(Chronos::parse('2015-01-01'), $meta->getValidSince());
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace ShlinkioTest\Shlink\Core\Paginator\Adapter;
|
||||
use Cake\Chronos\Chronos;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
|
||||
use Shlinkio\Shlink\Core\Paginator\Adapter\ShortUrlRepositoryAdapter;
|
||||
use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
|
||||
|
||||
@@ -21,17 +21,26 @@ class ShortUrlRepositoryAdapterTest extends TestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|array|null $orderBy
|
||||
* @test
|
||||
* @dataProvider provideFilteringArgs
|
||||
*/
|
||||
public function getItemsFallsBackToFindList(
|
||||
?string $searchTerm = null,
|
||||
array $tags = [],
|
||||
?DateRange $dateRange = null,
|
||||
$orderBy = null
|
||||
?string $startDate = null,
|
||||
?string $endDate = null,
|
||||
?string $orderBy = null
|
||||
): void {
|
||||
$adapter = new ShortUrlRepositoryAdapter($this->repo->reveal(), $searchTerm, $tags, $orderBy, $dateRange);
|
||||
$params = ShortUrlsParams::fromRawData([
|
||||
'searchTerm' => $searchTerm,
|
||||
'tags' => $tags,
|
||||
'startDate' => $startDate,
|
||||
'endDate' => $endDate,
|
||||
'orderBy' => $orderBy,
|
||||
]);
|
||||
$adapter = new ShortUrlRepositoryAdapter($this->repo->reveal(), $params);
|
||||
$orderBy = $params->orderBy();
|
||||
$dateRange = $params->dateRange();
|
||||
|
||||
$this->repo->findList(10, 5, $searchTerm, $tags, $orderBy, $dateRange)->shouldBeCalledOnce();
|
||||
$adapter->getItems(5, 10);
|
||||
@@ -44,9 +53,17 @@ class ShortUrlRepositoryAdapterTest extends TestCase
|
||||
public function countFallsBackToCountList(
|
||||
?string $searchTerm = null,
|
||||
array $tags = [],
|
||||
?DateRange $dateRange = null
|
||||
?string $startDate = null,
|
||||
?string $endDate = null
|
||||
): void {
|
||||
$adapter = new ShortUrlRepositoryAdapter($this->repo->reveal(), $searchTerm, $tags, null, $dateRange);
|
||||
$params = ShortUrlsParams::fromRawData([
|
||||
'searchTerm' => $searchTerm,
|
||||
'tags' => $tags,
|
||||
'startDate' => $startDate,
|
||||
'endDate' => $endDate,
|
||||
]);
|
||||
$adapter = new ShortUrlRepositoryAdapter($this->repo->reveal(), $params);
|
||||
$dateRange = $params->dateRange();
|
||||
|
||||
$this->repo->countList($searchTerm, $tags, $dateRange)->shouldBeCalledOnce();
|
||||
$adapter->count();
|
||||
@@ -58,12 +75,12 @@ class ShortUrlRepositoryAdapterTest extends TestCase
|
||||
yield ['search'];
|
||||
yield ['search', []];
|
||||
yield ['search', ['foo', 'bar']];
|
||||
yield ['search', ['foo', 'bar'], null, 'order'];
|
||||
yield ['search', ['foo', 'bar'], new DateRange(), 'order'];
|
||||
yield ['search', ['foo', 'bar'], new DateRange(Chronos::now()), 'order'];
|
||||
yield ['search', ['foo', 'bar'], new DateRange(null, Chronos::now()), 'order'];
|
||||
yield ['search', ['foo', 'bar'], new DateRange(Chronos::now(), Chronos::now()), 'order'];
|
||||
yield ['search', ['foo', 'bar'], new DateRange(Chronos::now())];
|
||||
yield [null, ['foo', 'bar'], new DateRange(Chronos::now(), Chronos::now())];
|
||||
yield ['search', ['foo', 'bar'], null, null, 'order'];
|
||||
yield ['search', ['foo', 'bar'], Chronos::now()->toAtomString(), null, 'order'];
|
||||
yield ['search', ['foo', 'bar'], null, Chronos::now()->toAtomString(), 'order'];
|
||||
yield ['search', ['foo', 'bar'], Chronos::now()->toAtomString(), Chronos::now()->toAtomString(), 'order'];
|
||||
yield [null, ['foo', 'bar'], Chronos::now()->toAtomString(), null, 'order'];
|
||||
yield [null, ['foo', 'bar'], Chronos::now()->toAtomString()];
|
||||
yield [null, ['foo', 'bar'], Chronos::now()->toAtomString(), Chronos::now()->toAtomString()];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,10 +12,11 @@ use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Exception\DeleteShortUrlException;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
use Shlinkio\Shlink\Core\Options\DeleteShortUrlsOptions;
|
||||
use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
|
||||
use Shlinkio\Shlink\Core\Service\ShortUrl\DeleteShortUrlService;
|
||||
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
|
||||
|
||||
use function Functional\map;
|
||||
use function range;
|
||||
@@ -24,20 +25,20 @@ use function sprintf;
|
||||
class DeleteShortUrlServiceTest extends TestCase
|
||||
{
|
||||
private ObjectProphecy $em;
|
||||
private ObjectProphecy $urlResolver;
|
||||
private string $shortCode;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$shortUrl = (new ShortUrl(''))->setVisits(
|
||||
new ArrayCollection(map(range(0, 10), fn () => new Visit(new ShortUrl(''), Visitor::emptyInstance()))),
|
||||
);
|
||||
$shortUrl = (new ShortUrl(''))->setVisits(new ArrayCollection(
|
||||
map(range(0, 10), fn () => new Visit(new ShortUrl(''), Visitor::emptyInstance())),
|
||||
));
|
||||
$this->shortCode = $shortUrl->getShortCode();
|
||||
|
||||
$this->em = $this->prophesize(EntityManagerInterface::class);
|
||||
|
||||
$repo = $this->prophesize(ShortUrlRepositoryInterface::class);
|
||||
$repo->findOneBy(Argument::type('array'))->willReturn($shortUrl);
|
||||
$this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
|
||||
$this->urlResolver = $this->prophesize(ShortUrlResolverInterface::class);
|
||||
$this->urlResolver->resolveShortUrl(Argument::cetera())->willReturn($shortUrl);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
@@ -51,7 +52,7 @@ class DeleteShortUrlServiceTest extends TestCase
|
||||
$this->shortCode,
|
||||
));
|
||||
|
||||
$service->deleteByShortCode($this->shortCode);
|
||||
$service->deleteByShortCode(new ShortUrlIdentifier($this->shortCode));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
@@ -62,7 +63,7 @@ class DeleteShortUrlServiceTest extends TestCase
|
||||
$remove = $this->em->remove(Argument::type(ShortUrl::class))->willReturn(null);
|
||||
$flush = $this->em->flush()->willReturn(null);
|
||||
|
||||
$service->deleteByShortCode($this->shortCode, true);
|
||||
$service->deleteByShortCode(new ShortUrlIdentifier($this->shortCode), true);
|
||||
|
||||
$remove->shouldHaveBeenCalledOnce();
|
||||
$flush->shouldHaveBeenCalledOnce();
|
||||
@@ -76,7 +77,7 @@ class DeleteShortUrlServiceTest extends TestCase
|
||||
$remove = $this->em->remove(Argument::type(ShortUrl::class))->willReturn(null);
|
||||
$flush = $this->em->flush()->willReturn(null);
|
||||
|
||||
$service->deleteByShortCode($this->shortCode);
|
||||
$service->deleteByShortCode(new ShortUrlIdentifier($this->shortCode));
|
||||
|
||||
$remove->shouldHaveBeenCalledOnce();
|
||||
$flush->shouldHaveBeenCalledOnce();
|
||||
@@ -90,7 +91,7 @@ class DeleteShortUrlServiceTest extends TestCase
|
||||
$remove = $this->em->remove(Argument::type(ShortUrl::class))->willReturn(null);
|
||||
$flush = $this->em->flush()->willReturn(null);
|
||||
|
||||
$service->deleteByShortCode($this->shortCode);
|
||||
$service->deleteByShortCode(new ShortUrlIdentifier($this->shortCode));
|
||||
|
||||
$remove->shouldHaveBeenCalledOnce();
|
||||
$flush->shouldHaveBeenCalledOnce();
|
||||
@@ -101,6 +102,6 @@ class DeleteShortUrlServiceTest extends TestCase
|
||||
return new DeleteShortUrlService($this->em->reveal(), new DeleteShortUrlsOptions([
|
||||
'visitsThreshold' => $visitsThreshold,
|
||||
'checkVisitsThreshold' => $checkVisitsThreshold,
|
||||
]));
|
||||
]), $this->urlResolver->reveal());
|
||||
}
|
||||
}
|
||||
|
||||
136
module/Core/test/Service/ShortUrl/ShortUrlResolverTest.php
Normal file
136
module/Core/test/Service/ShortUrl/ShortUrlResolverTest.php
Normal file
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Core\Service\ShortUrl;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
|
||||
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolver;
|
||||
|
||||
use function Functional\map;
|
||||
use function range;
|
||||
|
||||
class ShortUrlResolverTest extends TestCase
|
||||
{
|
||||
private ShortUrlResolver $urlResolver;
|
||||
private ObjectProphecy $em;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->em = $this->prophesize(EntityManagerInterface::class);
|
||||
$this->urlResolver = new ShortUrlResolver($this->em->reveal());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function shortCodeIsProperlyParsed(): void
|
||||
{
|
||||
$shortUrl = new ShortUrl('expected_url');
|
||||
$shortCode = $shortUrl->getShortCode();
|
||||
|
||||
$repo = $this->prophesize(ShortUrlRepositoryInterface::class);
|
||||
$findOne = $repo->findOne($shortCode, null)->willReturn($shortUrl);
|
||||
$getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
|
||||
|
||||
$result = $this->urlResolver->resolveShortUrl(new ShortUrlIdentifier($shortCode));
|
||||
|
||||
$this->assertSame($shortUrl, $result);
|
||||
$findOne->shouldHaveBeenCalledOnce();
|
||||
$getRepo->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function exceptionIsThrownIfShortcodeIsNotFound(): void
|
||||
{
|
||||
$shortCode = 'abc123';
|
||||
|
||||
$repo = $this->prophesize(ShortUrlRepositoryInterface::class);
|
||||
$findOne = $repo->findOne($shortCode, null)->willReturn(null);
|
||||
$getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
|
||||
|
||||
$this->expectException(ShortUrlNotFoundException::class);
|
||||
$findOne->shouldBeCalledOnce();
|
||||
$getRepo->shouldBeCalledOnce();
|
||||
|
||||
$this->urlResolver->resolveShortUrl(new ShortUrlIdentifier($shortCode));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function shortCodeToEnabledShortUrlProperlyParsesShortCode(): void
|
||||
{
|
||||
$shortUrl = new ShortUrl('expected_url');
|
||||
$shortCode = $shortUrl->getShortCode();
|
||||
|
||||
$repo = $this->prophesize(ShortUrlRepositoryInterface::class);
|
||||
$findOneByShortCode = $repo->findOneWithDomainFallback($shortCode, null)->willReturn($shortUrl);
|
||||
$getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
|
||||
|
||||
$result = $this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($shortCode));
|
||||
|
||||
$this->assertSame($shortUrl, $result);
|
||||
$findOneByShortCode->shouldHaveBeenCalledOnce();
|
||||
$getRepo->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideDisabledShortUrls
|
||||
*/
|
||||
public function shortCodeToEnabledShortUrlThrowsExceptionIfUrlIsNotEnabled(ShortUrl $shortUrl): void
|
||||
{
|
||||
$shortCode = $shortUrl->getShortCode();
|
||||
|
||||
$repo = $this->prophesize(ShortUrlRepositoryInterface::class);
|
||||
$findOneByShortCode = $repo->findOneWithDomainFallback($shortCode, null)->willReturn($shortUrl);
|
||||
$getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
|
||||
|
||||
$this->expectException(ShortUrlNotFoundException::class);
|
||||
$findOneByShortCode->shouldBeCalledOnce();
|
||||
$getRepo->shouldBeCalledOnce();
|
||||
|
||||
$this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($shortCode));
|
||||
}
|
||||
|
||||
public function provideDisabledShortUrls(): iterable
|
||||
{
|
||||
$now = Chronos::now();
|
||||
|
||||
yield 'maxVisits reached' => [(function () {
|
||||
$shortUrl = new ShortUrl('', ShortUrlMeta::fromRawData(['maxVisits' => 3]));
|
||||
$shortUrl->setVisits(new ArrayCollection(map(
|
||||
range(0, 4),
|
||||
fn () => new Visit($shortUrl, Visitor::emptyInstance()),
|
||||
)));
|
||||
|
||||
return $shortUrl;
|
||||
})()];
|
||||
yield 'future validSince' => [new ShortUrl('', ShortUrlMeta::fromRawData([
|
||||
'validSince' => $now->addMonth()->toAtomString(),
|
||||
]))];
|
||||
yield 'past validUntil' => [new ShortUrl('', ShortUrlMeta::fromRawData([
|
||||
'validUntil' => $now->subMonth()->toAtomString(),
|
||||
]))];
|
||||
yield 'mixed' => [(function () use ($now) {
|
||||
$shortUrl = new ShortUrl('', ShortUrlMeta::fromRawData([
|
||||
'maxVisits' => 3,
|
||||
'validUntil' => $now->subMonth()->toAtomString(),
|
||||
]));
|
||||
$shortUrl->setVisits(new ArrayCollection(map(
|
||||
range(0, 4),
|
||||
fn () => new Visit($shortUrl, Visitor::emptyInstance()),
|
||||
)));
|
||||
|
||||
return $shortUrl;
|
||||
})()];
|
||||
}
|
||||
}
|
||||
@@ -12,9 +12,11 @@ use Prophecy\Argument;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Entity\Tag;
|
||||
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
|
||||
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 function count;
|
||||
@@ -23,13 +25,17 @@ class ShortUrlServiceTest extends TestCase
|
||||
{
|
||||
private ShortUrlService $service;
|
||||
private ObjectProphecy $em;
|
||||
private ObjectProphecy $urlResolver;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->em = $this->prophesize(EntityManagerInterface::class);
|
||||
$this->em->persist(Argument::any())->willReturn(null);
|
||||
$this->em->flush()->willReturn(null);
|
||||
$this->service = new ShortUrlService($this->em->reveal());
|
||||
|
||||
$this->urlResolver = $this->prophesize(ShortUrlResolverInterface::class);
|
||||
|
||||
$this->service = new ShortUrlService($this->em->reveal(), $this->urlResolver->reveal());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
@@ -47,40 +53,25 @@ class ShortUrlServiceTest extends TestCase
|
||||
$repo->countList(Argument::cetera())->willReturn(count($list))->shouldBeCalledOnce();
|
||||
$this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
|
||||
|
||||
$list = $this->service->listShortUrls();
|
||||
$list = $this->service->listShortUrls(ShortUrlsParams::emptyInstance());
|
||||
$this->assertEquals(4, $list->getCurrentItemCount());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function exceptionIsThrownWhenSettingTagsOnInvalidShortcode(): void
|
||||
{
|
||||
$shortCode = 'abc123';
|
||||
$repo = $this->prophesize(ShortUrlRepository::class);
|
||||
$repo->findOneBy(['shortCode' => $shortCode])->willReturn(null)
|
||||
->shouldBeCalledOnce();
|
||||
$this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
|
||||
|
||||
$this->expectException(ShortUrlNotFoundException::class);
|
||||
$this->service->setTagsByShortCode($shortCode);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function providedTagsAreGetFromRepoAndSetToTheShortUrl(): void
|
||||
{
|
||||
$shortUrl = $this->prophesize(ShortUrl::class);
|
||||
$shortUrl->setTags(Argument::any())->shouldBeCalledOnce();
|
||||
$shortCode = 'abc123';
|
||||
$repo = $this->prophesize(ShortUrlRepository::class);
|
||||
$repo->findOneBy(['shortCode' => $shortCode])->willReturn($shortUrl->reveal())
|
||||
->shouldBeCalledOnce();
|
||||
$this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
|
||||
$this->urlResolver->resolveShortUrl(new ShortUrlIdentifier($shortCode))->willReturn($shortUrl->reveal())
|
||||
->shouldBeCalledOnce();
|
||||
|
||||
$tagRepo = $this->prophesize(EntityRepository::class);
|
||||
$tagRepo->findOneBy(['name' => 'foo'])->willReturn(new Tag('foo'))->shouldBeCalledOnce();
|
||||
$tagRepo->findOneBy(['name' => 'bar'])->willReturn(null)->shouldBeCalledOnce();
|
||||
$this->em->getRepository(Tag::class)->willReturn($tagRepo->reveal());
|
||||
|
||||
$this->service->setTagsByShortCode($shortCode, ['foo', 'bar']);
|
||||
$this->service->setTagsByShortCode(new ShortUrlIdentifier($shortCode), ['foo', 'bar']);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
@@ -88,16 +79,15 @@ class ShortUrlServiceTest extends TestCase
|
||||
{
|
||||
$shortUrl = new ShortUrl('');
|
||||
|
||||
$repo = $this->prophesize(ShortUrlRepository::class);
|
||||
$findShortUrl = $repo->findOneBy(['shortCode' => 'abc123'])->willReturn($shortUrl);
|
||||
$getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
|
||||
$findShortUrl = $this->urlResolver->resolveShortUrl(new ShortUrlIdentifier('abc123'))->willReturn($shortUrl);
|
||||
$flush = $this->em->flush()->willReturn(null);
|
||||
|
||||
$result = $this->service->updateMetadataByShortCode('abc123', ShortUrlMeta::createFromParams(
|
||||
Chronos::parse('2017-01-01 00:00:00')->toAtomString(),
|
||||
Chronos::parse('2017-01-05 00:00:00')->toAtomString(),
|
||||
null,
|
||||
5,
|
||||
$result = $this->service->updateMetadataByShortCode(new ShortUrlIdentifier('abc123'), ShortUrlMeta::fromRawData(
|
||||
[
|
||||
'validSince' => Chronos::parse('2017-01-01 00:00:00')->toAtomString(),
|
||||
'validUntil' => Chronos::parse('2017-01-05 00:00:00')->toAtomString(),
|
||||
'maxVisits' => 5,
|
||||
],
|
||||
));
|
||||
|
||||
$this->assertSame($shortUrl, $result);
|
||||
@@ -105,7 +95,6 @@ class ShortUrlServiceTest extends TestCase
|
||||
$this->assertEquals(Chronos::parse('2017-01-05 00:00:00'), $shortUrl->getValidUntil());
|
||||
$this->assertEquals(5, $shortUrl->getMaxVisits());
|
||||
$findShortUrl->shouldHaveBeenCalled();
|
||||
$getRepo->shouldHaveBeenCalled();
|
||||
$flush->shouldHaveBeenCalled();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
|
||||
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
|
||||
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
|
||||
use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
|
||||
use Shlinkio\Shlink\Core\Service\UrlShortener;
|
||||
use Shlinkio\Shlink\Core\Util\UrlValidatorInterface;
|
||||
|
||||
@@ -153,7 +152,7 @@ class UrlShortenerTest extends TestCase
|
||||
$this->urlShortener->urlToShortCode(
|
||||
new Uri('http://foobar.com/12345/hello?foo=bar'),
|
||||
[],
|
||||
ShortUrlMeta::createFromRawData(['customSlug' => 'custom-slug']),
|
||||
ShortUrlMeta::fromRawData(['customSlug' => 'custom-slug']),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -183,49 +182,49 @@ class UrlShortenerTest extends TestCase
|
||||
{
|
||||
$url = 'http://foo.com';
|
||||
|
||||
yield [$url, [], ShortUrlMeta::createFromRawData(['findIfExists' => true]), new ShortUrl($url)];
|
||||
yield [$url, [], ShortUrlMeta::createFromRawData(
|
||||
yield [$url, [], ShortUrlMeta::fromRawData(['findIfExists' => true]), new ShortUrl($url)];
|
||||
yield [$url, [], ShortUrlMeta::fromRawData(
|
||||
['findIfExists' => true, 'customSlug' => 'foo'],
|
||||
), new ShortUrl($url)];
|
||||
yield [
|
||||
$url,
|
||||
['foo', 'bar'],
|
||||
ShortUrlMeta::createFromRawData(['findIfExists' => true]),
|
||||
ShortUrlMeta::fromRawData(['findIfExists' => true]),
|
||||
(new ShortUrl($url))->setTags(new ArrayCollection([new Tag('bar'), new Tag('foo')])),
|
||||
];
|
||||
yield [
|
||||
$url,
|
||||
[],
|
||||
ShortUrlMeta::createFromRawData(['findIfExists' => true, 'maxVisits' => 3]),
|
||||
new ShortUrl($url, ShortUrlMeta::createFromRawData(['maxVisits' => 3])),
|
||||
ShortUrlMeta::fromRawData(['findIfExists' => true, 'maxVisits' => 3]),
|
||||
new ShortUrl($url, ShortUrlMeta::fromRawData(['maxVisits' => 3])),
|
||||
];
|
||||
yield [
|
||||
$url,
|
||||
[],
|
||||
ShortUrlMeta::createFromRawData(['findIfExists' => true, 'validSince' => Chronos::parse('2017-01-01')]),
|
||||
new ShortUrl($url, ShortUrlMeta::createFromRawData(['validSince' => Chronos::parse('2017-01-01')])),
|
||||
ShortUrlMeta::fromRawData(['findIfExists' => true, 'validSince' => Chronos::parse('2017-01-01')]),
|
||||
new ShortUrl($url, ShortUrlMeta::fromRawData(['validSince' => Chronos::parse('2017-01-01')])),
|
||||
];
|
||||
yield [
|
||||
$url,
|
||||
[],
|
||||
ShortUrlMeta::createFromRawData(['findIfExists' => true, 'validUntil' => Chronos::parse('2017-01-01')]),
|
||||
new ShortUrl($url, ShortUrlMeta::createFromRawData(['validUntil' => Chronos::parse('2017-01-01')])),
|
||||
ShortUrlMeta::fromRawData(['findIfExists' => true, 'validUntil' => Chronos::parse('2017-01-01')]),
|
||||
new ShortUrl($url, ShortUrlMeta::fromRawData(['validUntil' => Chronos::parse('2017-01-01')])),
|
||||
];
|
||||
yield [
|
||||
$url,
|
||||
[],
|
||||
ShortUrlMeta::createFromRawData(['findIfExists' => true, 'domain' => 'example.com']),
|
||||
new ShortUrl($url, ShortUrlMeta::createFromRawData(['domain' => 'example.com'])),
|
||||
ShortUrlMeta::fromRawData(['findIfExists' => true, 'domain' => 'example.com']),
|
||||
new ShortUrl($url, ShortUrlMeta::fromRawData(['domain' => 'example.com'])),
|
||||
];
|
||||
yield [
|
||||
$url,
|
||||
['baz', 'foo', 'bar'],
|
||||
ShortUrlMeta::createFromRawData([
|
||||
ShortUrlMeta::fromRawData([
|
||||
'findIfExists' => true,
|
||||
'validUntil' => Chronos::parse('2017-01-01'),
|
||||
'maxVisits' => 4,
|
||||
]),
|
||||
(new ShortUrl($url, ShortUrlMeta::createFromRawData([
|
||||
(new ShortUrl($url, ShortUrlMeta::fromRawData([
|
||||
'validUntil' => Chronos::parse('2017-01-01'),
|
||||
'maxVisits' => 4,
|
||||
])))->setTags(new ArrayCollection([new Tag('foo'), new Tag('bar'), new Tag('baz')])),
|
||||
@@ -237,7 +236,7 @@ class UrlShortenerTest extends TestCase
|
||||
{
|
||||
$url = 'http://foo.com';
|
||||
$tags = ['baz', 'foo', 'bar'];
|
||||
$meta = ShortUrlMeta::createFromRawData([
|
||||
$meta = ShortUrlMeta::fromRawData([
|
||||
'findIfExists' => true,
|
||||
'validUntil' => Chronos::parse('2017-01-01'),
|
||||
'maxVisits' => 4,
|
||||
@@ -260,18 +259,4 @@ class UrlShortenerTest extends TestCase
|
||||
$findExisting->shouldHaveBeenCalledOnce();
|
||||
$getRepo->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function shortCodeIsProperlyParsed(): void
|
||||
{
|
||||
$shortUrl = new ShortUrl('expected_url');
|
||||
$shortCode = $shortUrl->getShortCode();
|
||||
|
||||
$repo = $this->prophesize(ShortUrlRepositoryInterface::class);
|
||||
$repo->findOneByShortCode($shortCode, null)->willReturn($shortUrl);
|
||||
$this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
|
||||
|
||||
$url = $this->urlShortener->shortCodeToUrl($shortCode);
|
||||
$this->assertSame($shortUrl, $url);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
||||
namespace ShlinkioTest\Shlink\Core\Service;
|
||||
|
||||
use Doctrine\ORM\EntityManager;
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
use Laminas\Stdlib\ArrayUtils;
|
||||
use PHPUnit\Framework\Assert;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
@@ -16,11 +15,17 @@ use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\EventDispatcher\ShortUrlVisited;
|
||||
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
|
||||
use Shlinkio\Shlink\Core\Repository\VisitRepository;
|
||||
use Shlinkio\Shlink\Core\Service\VisitsTracker;
|
||||
|
||||
use function Functional\map;
|
||||
use function range;
|
||||
|
||||
class VisitsTrackerTest extends TestCase
|
||||
{
|
||||
private VisitsTracker $visitsTracker;
|
||||
@@ -39,14 +44,11 @@ class VisitsTrackerTest extends TestCase
|
||||
public function trackPersistsVisit(): void
|
||||
{
|
||||
$shortCode = '123ABC';
|
||||
$repo = $this->prophesize(EntityRepository::class);
|
||||
$repo->findOneBy(['shortCode' => $shortCode])->willReturn(new ShortUrl(''));
|
||||
|
||||
$this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal())->shouldBeCalledOnce();
|
||||
$this->em->persist(Argument::that(fn (Visit $visit) => $visit->setId('1')))->shouldBeCalledOnce();
|
||||
$this->em->flush()->shouldBeCalledOnce();
|
||||
|
||||
$this->visitsTracker->track($shortCode, Visitor::emptyInstance());
|
||||
$this->visitsTracker->track(new ShortUrl($shortCode), Visitor::emptyInstance());
|
||||
|
||||
$this->eventDispatcher->dispatch(Argument::type(ShortUrlVisited::class))->shouldHaveBeenCalled();
|
||||
}
|
||||
@@ -55,10 +57,7 @@ class VisitsTrackerTest extends TestCase
|
||||
public function trackedIpAddressGetsObfuscated(): void
|
||||
{
|
||||
$shortCode = '123ABC';
|
||||
$repo = $this->prophesize(EntityRepository::class);
|
||||
$repo->findOneBy(['shortCode' => $shortCode])->willReturn(new ShortUrl(''));
|
||||
|
||||
$this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal())->shouldBeCalledOnce();
|
||||
$this->em->persist(Argument::any())->will(function ($args) {
|
||||
/** @var Visit $visit */
|
||||
$visit = $args[0];
|
||||
@@ -68,7 +67,7 @@ class VisitsTrackerTest extends TestCase
|
||||
})->shouldBeCalledOnce();
|
||||
$this->em->flush()->shouldBeCalledOnce();
|
||||
|
||||
$this->visitsTracker->track($shortCode, new Visitor('', '', '4.3.2.1'));
|
||||
$this->visitsTracker->track(new ShortUrl($shortCode), new Visitor('', '', '4.3.2.1'));
|
||||
|
||||
$this->eventDispatcher->dispatch(Argument::type(ShortUrlVisited::class))->shouldHaveBeenCalled();
|
||||
}
|
||||
@@ -77,22 +76,33 @@ class VisitsTrackerTest extends TestCase
|
||||
public function infoReturnsVisitsForCertainShortCode(): void
|
||||
{
|
||||
$shortCode = '123ABC';
|
||||
$repo = $this->prophesize(EntityRepository::class);
|
||||
$count = $repo->count(['shortCode' => $shortCode])->willReturn(1);
|
||||
$repo = $this->prophesize(ShortUrlRepositoryInterface::class);
|
||||
$count = $repo->shortCodeIsInUse($shortCode, null)->willReturn(true);
|
||||
$this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal())->shouldBeCalledOnce();
|
||||
|
||||
$list = [
|
||||
new Visit(new ShortUrl(''), Visitor::emptyInstance()),
|
||||
new Visit(new ShortUrl(''), Visitor::emptyInstance()),
|
||||
];
|
||||
$list = map(range(0, 1), fn () => new Visit(new ShortUrl(''), Visitor::emptyInstance()));
|
||||
$repo2 = $this->prophesize(VisitRepository::class);
|
||||
$repo2->findVisitsByShortCode($shortCode, Argument::type(DateRange::class), 1, 0)->willReturn($list);
|
||||
$repo2->countVisitsByShortCode($shortCode, Argument::type(DateRange::class))->willReturn(1);
|
||||
$repo2->findVisitsByShortCode($shortCode, null, Argument::type(DateRange::class), 1, 0)->willReturn($list);
|
||||
$repo2->countVisitsByShortCode($shortCode, null, Argument::type(DateRange::class))->willReturn(1);
|
||||
$this->em->getRepository(Visit::class)->willReturn($repo2->reveal())->shouldBeCalledOnce();
|
||||
|
||||
$paginator = $this->visitsTracker->info($shortCode, new VisitsParams());
|
||||
$paginator = $this->visitsTracker->info(new ShortUrlIdentifier($shortCode), new VisitsParams());
|
||||
|
||||
$this->assertEquals($list, ArrayUtils::iteratorToArray($paginator->getCurrentItems()));
|
||||
$count->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function throwsExceptionWhenRequestingVisitsForInvalidShortCode(): void
|
||||
{
|
||||
$shortCode = '123ABC';
|
||||
$repo = $this->prophesize(ShortUrlRepositoryInterface::class);
|
||||
$count = $repo->shortCodeIsInUse($shortCode, null)->willReturn(false);
|
||||
$this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal())->shouldBeCalledOnce();
|
||||
|
||||
$this->expectException(ShortUrlNotFoundException::class);
|
||||
$count->shouldBeCalledOnce();
|
||||
|
||||
$this->visitsTracker->info(new ShortUrlIdentifier($shortCode), new VisitsParams());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,13 +42,13 @@ class ShortUrlDataTransformerTest extends TestCase
|
||||
'validUntil' => null,
|
||||
'maxVisits' => null,
|
||||
]];
|
||||
yield 'max visits only' => [new ShortUrl('', ShortUrlMeta::createFromParams(null, null, null, $maxVisits)), [
|
||||
yield 'max visits only' => [new ShortUrl('', ShortUrlMeta::fromRawData(['maxVisits' => $maxVisits])), [
|
||||
'validSince' => null,
|
||||
'validUntil' => null,
|
||||
'maxVisits' => $maxVisits,
|
||||
]];
|
||||
yield 'max visits and valid since' => [
|
||||
new ShortUrl('', ShortUrlMeta::createFromParams($now, null, null, $maxVisits)),
|
||||
new ShortUrl('', ShortUrlMeta::fromRawData(['validSince' => $now, 'maxVisits' => $maxVisits])),
|
||||
[
|
||||
'validSince' => $now->toAtomString(),
|
||||
'validUntil' => null,
|
||||
@@ -56,7 +56,9 @@ class ShortUrlDataTransformerTest extends TestCase
|
||||
],
|
||||
];
|
||||
yield 'both dates' => [
|
||||
new ShortUrl('', ShortUrlMeta::createFromParams($now, $now->subDays(10))),
|
||||
new ShortUrl('', ShortUrlMeta::fromRawData(
|
||||
['validSince' => $now, 'validUntil' => $now->subDays(10)],
|
||||
)),
|
||||
[
|
||||
'validSince' => $now->toAtomString(),
|
||||
'validUntil' => $now->subDays(10)->toAtomString(),
|
||||
@@ -64,7 +66,9 @@ class ShortUrlDataTransformerTest extends TestCase
|
||||
],
|
||||
];
|
||||
yield 'everything' => [
|
||||
new ShortUrl('', ShortUrlMeta::createFromParams($now, $now->subDays(5), null, $maxVisits)),
|
||||
new ShortUrl('', ShortUrlMeta::fromRawData(
|
||||
['validSince' => $now, 'validUntil' => $now->subDays(5), 'maxVisits' => $maxVisits],
|
||||
)),
|
||||
[
|
||||
'validSince' => $now->toAtomString(),
|
||||
'validUntil' => $now->subDays(5)->toAtomString(),
|
||||
|
||||
@@ -37,6 +37,7 @@ return [
|
||||
Middleware\BodyParserMiddleware::class => InvokableFactory::class,
|
||||
Middleware\CrossDomainMiddleware::class => InvokableFactory::class,
|
||||
Middleware\ShortUrl\CreateShortUrlContentNegotiationMiddleware::class => InvokableFactory::class,
|
||||
Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class => ConfigAbstractFactory::class,
|
||||
],
|
||||
],
|
||||
|
||||
@@ -57,7 +58,10 @@ return [
|
||||
],
|
||||
Action\ShortUrl\EditShortUrlAction::class => [Service\ShortUrlService::class, 'Logger_Shlink'],
|
||||
Action\ShortUrl\DeleteShortUrlAction::class => [Service\ShortUrl\DeleteShortUrlService::class, 'Logger_Shlink'],
|
||||
Action\ShortUrl\ResolveShortUrlAction::class => [Service\UrlShortener::class, 'config.url_shortener.domain'],
|
||||
Action\ShortUrl\ResolveShortUrlAction::class => [
|
||||
Service\ShortUrl\ShortUrlResolver::class,
|
||||
'config.url_shortener.domain',
|
||||
],
|
||||
Action\Visit\GetVisitsAction::class => [Service\VisitsTracker::class, 'Logger_Shlink'],
|
||||
Action\ShortUrl\ListShortUrlsAction::class => [
|
||||
Service\ShortUrlService::class,
|
||||
@@ -69,6 +73,8 @@ return [
|
||||
Action\Tag\DeleteTagsAction::class => [Service\Tag\TagService::class, LoggerInterface::class],
|
||||
Action\Tag\CreateTagsAction::class => [Service\Tag\TagService::class, LoggerInterface::class],
|
||||
Action\Tag\UpdateTagAction::class => [Service\Tag\TagService::class, LoggerInterface::class],
|
||||
|
||||
Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class => ['config.url_shortener.domain.hostname'],
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -6,29 +6,32 @@ namespace Shlinkio\Shlink\Rest;
|
||||
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping\Builder\ClassMetadataBuilder;
|
||||
use Doctrine\ORM\Mapping\ClassMetadata; // @codingStandardsIgnoreLine
|
||||
use Doctrine\ORM\Mapping\ClassMetadata;
|
||||
use Shlinkio\Shlink\Common\Doctrine\Type\ChronosDateTimeType;
|
||||
|
||||
/** @var $metadata ClassMetadata */ // @codingStandardsIgnoreLine
|
||||
$builder = new ClassMetadataBuilder($metadata);
|
||||
use function Shlinkio\Shlink\Core\determineTableName;
|
||||
|
||||
$builder->setTable('api_keys');
|
||||
return static function (ClassMetadata $metadata, array $emConfig): void {
|
||||
$builder = new ClassMetadataBuilder($metadata);
|
||||
|
||||
$builder->createField('id', Types::BIGINT)
|
||||
->makePrimaryKey()
|
||||
->generatedValue('IDENTITY')
|
||||
->option('unsigned', true)
|
||||
->build();
|
||||
$builder->setTable(determineTableName('api_keys', $emConfig));
|
||||
|
||||
$builder->createField('key', Types::STRING)
|
||||
->columnName('`key`')
|
||||
->unique()
|
||||
->build();
|
||||
$builder->createField('id', Types::BIGINT)
|
||||
->makePrimaryKey()
|
||||
->generatedValue('IDENTITY')
|
||||
->option('unsigned', true)
|
||||
->build();
|
||||
|
||||
$builder->createField('expirationDate', ChronosDateTimeType::CHRONOS_DATETIME)
|
||||
->columnName('expiration_date')
|
||||
->nullable()
|
||||
->build();
|
||||
$builder->createField('key', Types::STRING)
|
||||
->columnName('`key`')
|
||||
->unique()
|
||||
->build();
|
||||
|
||||
$builder->createField('enabled', Types::BOOLEAN)
|
||||
->build();
|
||||
$builder->createField('expirationDate', ChronosDateTimeType::CHRONOS_DATETIME)
|
||||
->columnName('expiration_date')
|
||||
->nullable()
|
||||
->build();
|
||||
|
||||
$builder->createField('enabled', Types::BOOLEAN)
|
||||
->build();
|
||||
};
|
||||
|
||||
@@ -4,26 +4,25 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Rest;
|
||||
|
||||
$contentNegotiationMiddleware = Middleware\ShortUrl\CreateShortUrlContentNegotiationMiddleware::class;
|
||||
$dropDomainMiddleware = Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class;
|
||||
|
||||
return [
|
||||
|
||||
'routes' => [
|
||||
Action\HealthAction::getRouteDef(),
|
||||
|
||||
// Short codes
|
||||
Action\ShortUrl\CreateShortUrlAction::getRouteDef([
|
||||
Middleware\ShortUrl\CreateShortUrlContentNegotiationMiddleware::class,
|
||||
]),
|
||||
Action\ShortUrl\SingleStepCreateShortUrlAction::getRouteDef([
|
||||
Middleware\ShortUrl\CreateShortUrlContentNegotiationMiddleware::class,
|
||||
]),
|
||||
Action\ShortUrl\EditShortUrlAction::getRouteDef(),
|
||||
Action\ShortUrl\DeleteShortUrlAction::getRouteDef(),
|
||||
Action\ShortUrl\ResolveShortUrlAction::getRouteDef(),
|
||||
Action\ShortUrl\CreateShortUrlAction::getRouteDef([$contentNegotiationMiddleware, $dropDomainMiddleware]),
|
||||
Action\ShortUrl\SingleStepCreateShortUrlAction::getRouteDef([$contentNegotiationMiddleware]),
|
||||
Action\ShortUrl\EditShortUrlAction::getRouteDef([$dropDomainMiddleware]),
|
||||
Action\ShortUrl\DeleteShortUrlAction::getRouteDef([$dropDomainMiddleware]),
|
||||
Action\ShortUrl\ResolveShortUrlAction::getRouteDef([$dropDomainMiddleware]),
|
||||
Action\ShortUrl\ListShortUrlsAction::getRouteDef(),
|
||||
Action\ShortUrl\EditShortUrlTagsAction::getRouteDef(),
|
||||
Action\ShortUrl\EditShortUrlTagsAction::getRouteDef([$dropDomainMiddleware]),
|
||||
|
||||
// Visits
|
||||
Action\Visit\GetVisitsAction::getRouteDef(),
|
||||
Action\Visit\GetVisitsAction::getRouteDef([$dropDomainMiddleware]),
|
||||
|
||||
// Tags
|
||||
Action\Tag\ListTagsAction::getRouteDef(),
|
||||
|
||||
@@ -27,15 +27,7 @@ class CreateShortUrlAction extends AbstractCreateShortUrlAction
|
||||
]);
|
||||
}
|
||||
|
||||
$meta = ShortUrlMeta::createFromParams(
|
||||
$postData['validSince'] ?? null,
|
||||
$postData['validUntil'] ?? null,
|
||||
$postData['customSlug'] ?? null,
|
||||
$postData['maxVisits'] ?? null,
|
||||
$postData['findIfExists'] ?? null,
|
||||
$postData['domain'] ?? null,
|
||||
);
|
||||
|
||||
$meta = ShortUrlMeta::fromRawData($postData);
|
||||
return new CreateShortUrlData(new Uri($postData['longUrl']), (array) ($postData['tags'] ?? []), $meta);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use Laminas\Diactoros\Response\EmptyResponse;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Service\ShortUrl\DeleteShortUrlServiceInterface;
|
||||
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
|
||||
|
||||
@@ -26,8 +27,8 @@ class DeleteShortUrlAction extends AbstractRestAction
|
||||
|
||||
public function handle(ServerRequestInterface $request): ResponseInterface
|
||||
{
|
||||
$shortCode = $request->getAttribute('shortCode', '');
|
||||
$this->deleteShortUrlService->deleteByShortCode($shortCode);
|
||||
$identifier = ShortUrlIdentifier::fromApiRequest($request);
|
||||
$this->deleteShortUrlService->deleteByShortCode($identifier);
|
||||
return new EmptyResponse();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use Laminas\Diactoros\Response\EmptyResponse;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
|
||||
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
|
||||
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
|
||||
@@ -28,9 +29,9 @@ class EditShortUrlAction extends AbstractRestAction
|
||||
public function handle(ServerRequestInterface $request): ResponseInterface
|
||||
{
|
||||
$postData = (array) $request->getParsedBody();
|
||||
$shortCode = $request->getAttribute('shortCode', '');
|
||||
$identifier = ShortUrlIdentifier::fromApiRequest($request);
|
||||
|
||||
$this->shortUrlService->updateMetadataByShortCode($shortCode, ShortUrlMeta::createFromRawData($postData));
|
||||
$this->shortUrlService->updateMetadataByShortCode($identifier, ShortUrlMeta::fromRawData($postData));
|
||||
return new EmptyResponse();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shlinkio\Shlink\Core\Exception\ValidationException;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
|
||||
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
|
||||
|
||||
@@ -27,7 +28,6 @@ class EditShortUrlTagsAction extends AbstractRestAction
|
||||
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
$shortCode = $request->getAttribute('shortCode');
|
||||
$bodyParams = $request->getParsedBody();
|
||||
|
||||
if (! isset($bodyParams['tags'])) {
|
||||
@@ -35,9 +35,10 @@ class EditShortUrlTagsAction extends AbstractRestAction
|
||||
'tags' => 'List of tags has to be provided',
|
||||
]);
|
||||
}
|
||||
$tags = $bodyParams['tags'];
|
||||
['tags' => $tags] = $bodyParams;
|
||||
$identifier = ShortUrlIdentifier::fromApiRequest($request);
|
||||
|
||||
$shortUrl = $this->shortUrlService->setTagsByShortCode($shortCode, $tags);
|
||||
$shortUrl = $this->shortUrlService->setTagsByShortCode($identifier, $tags);
|
||||
return new JsonResponse(['tags' => $shortUrl->getTags()->toArray()]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,12 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Rest\Action\ShortUrl;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use InvalidArgumentException;
|
||||
use Laminas\Diactoros\Response\JsonResponse;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shlinkio\Shlink\Common\Paginator\Util\PaginatorUtilsTrait;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
|
||||
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
|
||||
use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer;
|
||||
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
|
||||
@@ -36,38 +34,11 @@ class ListShortUrlsAction extends AbstractRestAction
|
||||
$this->domainConfig = $domainConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
$params = $this->queryToListParams($request->getQueryParams());
|
||||
$shortUrls = $this->shortUrlService->listShortUrls(...$params);
|
||||
$shortUrls = $this->shortUrlService->listShortUrls(ShortUrlsParams::fromRawData($request->getQueryParams()));
|
||||
return new JsonResponse(['shortUrls' => $this->serializePaginator($shortUrls, new ShortUrlDataTransformer(
|
||||
$this->domainConfig,
|
||||
))]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $query
|
||||
* @return array
|
||||
*/
|
||||
private function queryToListParams(array $query): array
|
||||
{
|
||||
return [
|
||||
(int) ($query['page'] ?? 1),
|
||||
$query['searchTerm'] ?? null,
|
||||
$query['tags'] ?? [],
|
||||
$query['orderBy'] ?? null,
|
||||
$this->determineDateRangeFromQuery($query),
|
||||
];
|
||||
}
|
||||
|
||||
private function determineDateRangeFromQuery(array $query): DateRange
|
||||
{
|
||||
return new DateRange(
|
||||
isset($query['startDate']) ? Chronos::parse($query['startDate']) : null,
|
||||
isset($query['endDate']) ? Chronos::parse($query['endDate']) : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,12 +4,12 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Rest\Action\ShortUrl;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use Laminas\Diactoros\Response\JsonResponse;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
|
||||
use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer;
|
||||
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
|
||||
|
||||
@@ -18,29 +18,24 @@ class ResolveShortUrlAction extends AbstractRestAction
|
||||
protected const ROUTE_PATH = '/short-urls/{shortCode}';
|
||||
protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET];
|
||||
|
||||
private UrlShortenerInterface $urlShortener;
|
||||
private ShortUrlResolverInterface $urlResolver;
|
||||
private array $domainConfig;
|
||||
|
||||
public function __construct(
|
||||
UrlShortenerInterface $urlShortener,
|
||||
ShortUrlResolverInterface $urlResolver,
|
||||
array $domainConfig,
|
||||
?LoggerInterface $logger = null
|
||||
) {
|
||||
parent::__construct($logger);
|
||||
$this->urlShortener = $urlShortener;
|
||||
$this->urlResolver = $urlResolver;
|
||||
$this->domainConfig = $domainConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
$shortCode = $request->getAttribute('shortCode');
|
||||
$domain = $request->getQueryParams()['domain'] ?? null;
|
||||
$transformer = new ShortUrlDataTransformer($this->domainConfig);
|
||||
$url = $this->urlResolver->resolveShortUrl(ShortUrlIdentifier::fromApiRequest($request));
|
||||
|
||||
$url = $this->urlShortener->shortCodeToUrl($shortCode, $domain);
|
||||
return new JsonResponse($transformer->transform($url));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shlinkio\Shlink\Common\Paginator\Util\PaginatorUtilsTrait;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
|
||||
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
|
||||
@@ -30,8 +31,8 @@ class GetVisitsAction extends AbstractRestAction
|
||||
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
$shortCode = $request->getAttribute('shortCode');
|
||||
$visits = $this->visitsTracker->info($shortCode, VisitsParams::fromRawData($request->getQueryParams()));
|
||||
$identifier = ShortUrlIdentifier::fromApiRequest($request);
|
||||
$visits = $this->visitsTracker->info($identifier, VisitsParams::fromRawData($request->getQueryParams()));
|
||||
|
||||
return new JsonResponse([
|
||||
'visits' => $this->serializePaginator($visits),
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Rest\Middleware\ShortUrl;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
|
||||
class DropDefaultDomainFromRequestMiddleware implements MiddlewareInterface
|
||||
{
|
||||
private string $defaultDomain;
|
||||
|
||||
public function __construct(string $defaultDomain)
|
||||
{
|
||||
$this->defaultDomain = $defaultDomain;
|
||||
}
|
||||
|
||||
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||
{
|
||||
$request = $request->withQueryParams($this->sanitizeDomainFromPayload($request->getQueryParams()))
|
||||
->withParsedBody($this->sanitizeDomainFromPayload($request->getParsedBody()));
|
||||
|
||||
return $handler->handle($request);
|
||||
}
|
||||
|
||||
private function sanitizeDomainFromPayload(array $payload): array
|
||||
{
|
||||
if (isset($payload['domain']) && $payload['domain'] === $this->defaultDomain) {
|
||||
unset($payload['domain']);
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
}
|
||||
@@ -228,6 +228,22 @@ class CreateShortUrlActionTest extends ApiTestCase
|
||||
$this->assertEquals($url, $payload['url']);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function defaultDomainIsDroppedIfProvided(): void
|
||||
{
|
||||
[$createStatusCode, ['shortCode' => $shortCode]] = $this->createShortUrl([
|
||||
'longUrl' => 'https://www.alejandrocelaya.com',
|
||||
'domain' => 'doma.in',
|
||||
]);
|
||||
$getResp = $this->callApiWithKey(self::METHOD_GET, '/short-urls/' . $shortCode);
|
||||
$payload = $this->getJsonResponsePayload($getResp);
|
||||
|
||||
$this->assertEquals(self::STATUS_OK, $createStatusCode);
|
||||
$this->assertEquals(self::STATUS_OK, $getResp->getStatusCode());
|
||||
$this->assertArrayHasKey('domain', $payload);
|
||||
$this->assertNull($payload['domain']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array {
|
||||
* @var int $statusCode
|
||||
|
||||
@@ -5,15 +5,22 @@ declare(strict_types=1);
|
||||
namespace ShlinkioApiTest\Shlink\Rest\Action;
|
||||
|
||||
use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase;
|
||||
use ShlinkioApiTest\Shlink\Rest\Utils\NotFoundUrlHelpersTrait;
|
||||
|
||||
class DeleteShortUrlActionTest extends ApiTestCase
|
||||
{
|
||||
/** @test */
|
||||
public function notFoundErrorIsReturnWhenDeletingInvalidUrl(): void
|
||||
{
|
||||
$expectedDetail = 'No URL found with short code "invalid"';
|
||||
use NotFoundUrlHelpersTrait;
|
||||
|
||||
$resp = $this->callApiWithKey(self::METHOD_DELETE, '/short-urls/invalid');
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideInvalidUrls
|
||||
*/
|
||||
public function notFoundErrorIsReturnWhenDeletingInvalidUrl(
|
||||
string $shortCode,
|
||||
?string $domain,
|
||||
string $expectedDetail
|
||||
): void {
|
||||
$resp = $this->callApiWithKey(self::METHOD_DELETE, $this->buildShortUrlPath($shortCode, $domain));
|
||||
$payload = $this->getJsonResponsePayload($resp);
|
||||
|
||||
$this->assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode());
|
||||
@@ -21,7 +28,8 @@ class DeleteShortUrlActionTest extends ApiTestCase
|
||||
$this->assertEquals('INVALID_SHORTCODE', $payload['type']);
|
||||
$this->assertEquals($expectedDetail, $payload['detail']);
|
||||
$this->assertEquals('Short URL not found', $payload['title']);
|
||||
$this->assertEquals('invalid', $payload['shortCode']);
|
||||
$this->assertEquals($shortCode, $payload['shortCode']);
|
||||
$this->assertEquals($domain, $payload['domain'] ?? null);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
@@ -42,4 +50,20 @@ class DeleteShortUrlActionTest extends ApiTestCase
|
||||
$this->assertEquals($expectedDetail, $payload['detail']);
|
||||
$this->assertEquals('Cannot delete short URL', $payload['title']);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function properShortUrlIsDeletedWhenDomainIsProvided(): void
|
||||
{
|
||||
$fetchWithDomainBefore = $this->callApiWithKey(self::METHOD_GET, '/short-urls/ghi789?domain=example.com');
|
||||
$fetchWithoutDomainBefore = $this->callApiWithKey(self::METHOD_GET, '/short-urls/ghi789');
|
||||
$deleteResp = $this->callApiWithKey(self::METHOD_DELETE, '/short-urls/ghi789?domain=example.com');
|
||||
$fetchWithDomainAfter = $this->callApiWithKey(self::METHOD_GET, '/short-urls/ghi789?domain=example.com');
|
||||
$fetchWithoutDomainAfter = $this->callApiWithKey(self::METHOD_GET, '/short-urls/ghi789');
|
||||
|
||||
$this->assertEquals(self::STATUS_OK, $fetchWithDomainBefore->getStatusCode());
|
||||
$this->assertEquals(self::STATUS_OK, $fetchWithoutDomainBefore->getStatusCode());
|
||||
$this->assertEquals(self::STATUS_NO_CONTENT, $deleteResp->getStatusCode());
|
||||
$this->assertEquals(self::STATUS_NOT_FOUND, $fetchWithDomainAfter->getStatusCode());
|
||||
$this->assertEquals(self::STATUS_OK, $fetchWithoutDomainAfter->getStatusCode());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,17 +4,84 @@ declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioApiTest\Shlink\Rest\Action;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use DMS\PHPUnitExtensions\ArraySubset\ArraySubsetAsserts;
|
||||
use GuzzleHttp\RequestOptions;
|
||||
use Laminas\Diactoros\Uri;
|
||||
use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase;
|
||||
use ShlinkioApiTest\Shlink\Rest\Utils\NotFoundUrlHelpersTrait;
|
||||
|
||||
use function GuzzleHttp\Psr7\build_query;
|
||||
use function sprintf;
|
||||
|
||||
class EditShortUrlActionTest extends ApiTestCase
|
||||
{
|
||||
/** @test */
|
||||
public function tryingToEditInvalidUrlReturnsNotFoundError(): void
|
||||
{
|
||||
$expectedDetail = 'No URL found with short code "invalid"';
|
||||
use ArraySubsetAsserts;
|
||||
use NotFoundUrlHelpersTrait;
|
||||
|
||||
$resp = $this->callApiWithKey(self::METHOD_PATCH, '/short-urls/invalid', [RequestOptions::JSON => []]);
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideMeta
|
||||
*/
|
||||
public function metadataCanBeReset(array $meta): void
|
||||
{
|
||||
$shortCode = 'abc123';
|
||||
$url = sprintf('/short-urls/%s', $shortCode);
|
||||
$resetMeta = [
|
||||
'validSince' => null,
|
||||
'validUntil' => null,
|
||||
'maxVisits' => null,
|
||||
];
|
||||
|
||||
$editWithProvidedMeta = $this->callApiWithKey(self::METHOD_PATCH, $url, [RequestOptions::JSON => $meta]);
|
||||
$metaAfterEditing = $this->findShortUrlMetaByShortCode($shortCode);
|
||||
|
||||
$editWithResetMeta = $this->callApiWithKey(self::METHOD_PATCH, $url, [
|
||||
RequestOptions::JSON => $resetMeta,
|
||||
]);
|
||||
$metaAfterResetting = $this->findShortUrlMetaByShortCode($shortCode);
|
||||
|
||||
$this->assertEquals(self::STATUS_NO_CONTENT, $editWithProvidedMeta->getStatusCode());
|
||||
$this->assertEquals(self::STATUS_NO_CONTENT, $editWithResetMeta->getStatusCode());
|
||||
$this->assertEquals($resetMeta, $metaAfterResetting);
|
||||
self::assertArraySubset($meta, $metaAfterEditing);
|
||||
}
|
||||
|
||||
public function provideMeta(): iterable
|
||||
{
|
||||
$now = Chronos::now();
|
||||
|
||||
yield [['validSince' => $now->addMonth()->toAtomString()]];
|
||||
yield [['validUntil' => $now->subMonth()->toAtomString()]];
|
||||
yield [['maxVisits' => 20]];
|
||||
yield [['validUntil' => $now->addYear()->toAtomString(), 'maxVisits' => 100]];
|
||||
yield [[
|
||||
'validSince' => $now->subYear()->toAtomString(),
|
||||
'validUntil' => $now->addYear()->toAtomString(),
|
||||
'maxVisits' => 100,
|
||||
]];
|
||||
}
|
||||
|
||||
private function findShortUrlMetaByShortCode(string $shortCode): ?array
|
||||
{
|
||||
$matchingShortUrl = $this->getJsonResponsePayload(
|
||||
$this->callApiWithKey(self::METHOD_GET, '/short-urls/' . $shortCode),
|
||||
);
|
||||
|
||||
return $matchingShortUrl['meta'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideInvalidUrls
|
||||
*/
|
||||
public function tryingToEditInvalidUrlReturnsNotFoundError(
|
||||
string $shortCode,
|
||||
?string $domain,
|
||||
string $expectedDetail
|
||||
): void {
|
||||
$url = $this->buildShortUrlPath($shortCode, $domain);
|
||||
$resp = $this->callApiWithKey(self::METHOD_PATCH, $url, [RequestOptions::JSON => []]);
|
||||
$payload = $this->getJsonResponsePayload($resp);
|
||||
|
||||
$this->assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode());
|
||||
@@ -22,7 +89,8 @@ class EditShortUrlActionTest extends ApiTestCase
|
||||
$this->assertEquals('INVALID_SHORTCODE', $payload['type']);
|
||||
$this->assertEquals($expectedDetail, $payload['detail']);
|
||||
$this->assertEquals('Short URL not found', $payload['title']);
|
||||
$this->assertEquals('invalid', $payload['shortCode']);
|
||||
$this->assertEquals($shortCode, $payload['shortCode']);
|
||||
$this->assertEquals($domain, $payload['domain'] ?? null);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
@@ -41,4 +109,37 @@ class EditShortUrlActionTest extends ApiTestCase
|
||||
$this->assertEquals($expectedDetail, $payload['detail']);
|
||||
$this->assertEquals('Invalid data', $payload['title']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideDomains
|
||||
*/
|
||||
public function metadataIsEditedOnProperShortUrlBasedOnDomain(?string $domain, string $expectedUrl): void
|
||||
{
|
||||
$shortCode = 'ghi789';
|
||||
$url = new Uri(sprintf('/short-urls/%s', $shortCode));
|
||||
|
||||
if ($domain !== null) {
|
||||
$url = $url->withQuery(build_query(['domain' => $domain]));
|
||||
}
|
||||
|
||||
$editResp = $this->callApiWithKey(self::METHOD_PATCH, (string) $url, [RequestOptions::JSON => [
|
||||
'maxVisits' => 100,
|
||||
]]);
|
||||
$editedShortUrl = $this->getJsonResponsePayload($this->callApiWithKey(self::METHOD_GET, (string) $url));
|
||||
|
||||
$this->assertEquals(self::STATUS_NO_CONTENT, $editResp->getStatusCode());
|
||||
$this->assertEquals($domain, $editedShortUrl['domain']);
|
||||
$this->assertEquals($expectedUrl, $editedShortUrl['longUrl']);
|
||||
$this->assertEquals(100, $editedShortUrl['meta']['maxVisits'] ?? null);
|
||||
}
|
||||
|
||||
public function provideDomains(): iterable
|
||||
{
|
||||
yield 'domain' => [
|
||||
'example.com',
|
||||
'https://blog.alejandrocelaya.com/2019/04/27/considerations-to-properly-use-open-source-software-projects/',
|
||||
];
|
||||
yield 'no domain' => [null, 'https://shlink.io/documentation/'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,12 @@ namespace ShlinkioApiTest\Shlink\Rest\Action;
|
||||
|
||||
use GuzzleHttp\RequestOptions;
|
||||
use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase;
|
||||
use ShlinkioApiTest\Shlink\Rest\Utils\NotFoundUrlHelpersTrait;
|
||||
|
||||
class EditShortUrlTagsActionTest extends ApiTestCase
|
||||
{
|
||||
use NotFoundUrlHelpersTrait;
|
||||
|
||||
/** @test */
|
||||
public function notProvidingTagsReturnsBadRequest(): void
|
||||
{
|
||||
@@ -24,12 +27,17 @@ class EditShortUrlTagsActionTest extends ApiTestCase
|
||||
$this->assertEquals('Invalid data', $payload['title']);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function providingInvalidShortCodeReturnsBadRequest(): void
|
||||
{
|
||||
$expectedDetail = 'No URL found with short code "invalid"';
|
||||
|
||||
$resp = $this->callApiWithKey(self::METHOD_PUT, '/short-urls/invalid/tags', [RequestOptions::JSON => [
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideInvalidUrls
|
||||
*/
|
||||
public function providingInvalidShortCodeReturnsBadRequest(
|
||||
string $shortCode,
|
||||
?string $domain,
|
||||
string $expectedDetail
|
||||
): void {
|
||||
$url = $this->buildShortUrlPath($shortCode, $domain, '/tags');
|
||||
$resp = $this->callApiWithKey(self::METHOD_PUT, $url, [RequestOptions::JSON => [
|
||||
'tags' => ['foo', 'bar'],
|
||||
]]);
|
||||
$payload = $this->getJsonResponsePayload($resp);
|
||||
@@ -39,6 +47,28 @@ class EditShortUrlTagsActionTest extends ApiTestCase
|
||||
$this->assertEquals('INVALID_SHORTCODE', $payload['type']);
|
||||
$this->assertEquals($expectedDetail, $payload['detail']);
|
||||
$this->assertEquals('Short URL not found', $payload['title']);
|
||||
$this->assertEquals('invalid', $payload['shortCode']);
|
||||
$this->assertEquals($shortCode, $payload['shortCode']);
|
||||
$this->assertEquals($domain, $payload['domain'] ?? null);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function tagsAreSetOnProperShortUrlBasedOnProvidedDomain(): void
|
||||
{
|
||||
$urlWithoutDomain = '/short-urls/ghi789/tags';
|
||||
$urlWithDomain = $urlWithoutDomain . '?domain=example.com';
|
||||
|
||||
$setTagsWithDomain = $this->callApiWithKey(self::METHOD_PUT, $urlWithDomain, [RequestOptions::JSON => [
|
||||
'tags' => ['foo', 'bar'],
|
||||
]]);
|
||||
$fetchWithoutDomain = $this->getJsonResponsePayload(
|
||||
$this->callApiWithKey(self::METHOD_GET, '/short-urls/ghi789'),
|
||||
);
|
||||
$fetchWithDomain = $this->getJsonResponsePayload(
|
||||
$this->callApiWithKey(self::METHOD_GET, '/short-urls/ghi789?domain=example.com'),
|
||||
);
|
||||
|
||||
$this->assertEquals(self::STATUS_OK, $setTagsWithDomain->getStatusCode());
|
||||
$this->assertEquals([], $fetchWithoutDomain['tags']);
|
||||
$this->assertEquals(['bar', 'foo'], $fetchWithDomain['tags']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,16 +4,27 @@ declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioApiTest\Shlink\Rest\Action;
|
||||
|
||||
use Laminas\Diactoros\Uri;
|
||||
use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase;
|
||||
use ShlinkioApiTest\Shlink\Rest\Utils\NotFoundUrlHelpersTrait;
|
||||
|
||||
use function GuzzleHttp\Psr7\build_query;
|
||||
use function sprintf;
|
||||
|
||||
class GetVisitsActionTest extends ApiTestCase
|
||||
{
|
||||
/** @test */
|
||||
public function tryingToGetVisitsForInvalidUrlReturnsNotFoundError(): void
|
||||
{
|
||||
$expectedDetail = 'No URL found with short code "invalid"';
|
||||
use NotFoundUrlHelpersTrait;
|
||||
|
||||
$resp = $this->callApiWithKey(self::METHOD_GET, '/short-urls/invalid/visits');
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideInvalidUrls
|
||||
*/
|
||||
public function tryingToGetVisitsForInvalidUrlReturnsNotFoundError(
|
||||
string $shortCode,
|
||||
?string $domain,
|
||||
string $expectedDetail
|
||||
): void {
|
||||
$resp = $this->callApiWithKey(self::METHOD_GET, $this->buildShortUrlPath($shortCode, $domain, '/visits'));
|
||||
$payload = $this->getJsonResponsePayload($resp);
|
||||
|
||||
$this->assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode());
|
||||
@@ -21,6 +32,33 @@ class GetVisitsActionTest extends ApiTestCase
|
||||
$this->assertEquals('INVALID_SHORTCODE', $payload['type']);
|
||||
$this->assertEquals($expectedDetail, $payload['detail']);
|
||||
$this->assertEquals('Short URL not found', $payload['title']);
|
||||
$this->assertEquals('invalid', $payload['shortCode']);
|
||||
$this->assertEquals($shortCode, $payload['shortCode']);
|
||||
$this->assertEquals($domain, $payload['domain'] ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideDomains
|
||||
*/
|
||||
public function properVisitsAreReturnedWhenDomainIsProvided(?string $domain, int $expectedAmountOfVisits): void
|
||||
{
|
||||
$shortCode = 'ghi789';
|
||||
$url = new Uri(sprintf('/short-urls/%s/visits', $shortCode));
|
||||
|
||||
if ($domain !== null) {
|
||||
$url = $url->withQuery(build_query(['domain' => $domain]));
|
||||
}
|
||||
|
||||
$resp = $this->callApiWithKey(self::METHOD_GET, (string) $url);
|
||||
$payload = $this->getJsonResponsePayload($resp);
|
||||
|
||||
$this->assertEquals($expectedAmountOfVisits, $payload['visits']['pagination']['totalItems'] ?? -1);
|
||||
$this->assertCount($expectedAmountOfVisits, $payload['visits']['data'] ?? []);
|
||||
}
|
||||
|
||||
public function provideDomains(): iterable
|
||||
{
|
||||
yield 'domain' => ['example.com', 0];
|
||||
yield 'no domain' => [null, 2];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,21 @@ class ListShortUrlsTest extends ApiTestCase
|
||||
'validUntil' => null,
|
||||
'maxVisits' => null,
|
||||
],
|
||||
'domain' => null,
|
||||
];
|
||||
private const SHORT_URL_DOCS = [
|
||||
'shortCode' => 'ghi789',
|
||||
'shortUrl' => 'http://doma.in/ghi789',
|
||||
'longUrl' => 'https://shlink.io/documentation/',
|
||||
'dateCreated' => '2018-05-01T00:00:00+00:00',
|
||||
'visitsCount' => 2,
|
||||
'tags' => [],
|
||||
'meta' => [
|
||||
'validSince' => null,
|
||||
'validUntil' => null,
|
||||
'maxVisits' => null,
|
||||
],
|
||||
'domain' => null,
|
||||
];
|
||||
private const SHORT_URL_CUSTOM_SLUG_AND_DOMAIN = [
|
||||
'shortCode' => 'custom-with-domain',
|
||||
@@ -37,6 +52,7 @@ class ListShortUrlsTest extends ApiTestCase
|
||||
'validUntil' => null,
|
||||
'maxVisits' => null,
|
||||
],
|
||||
'domain' => 'some-domain.com',
|
||||
];
|
||||
private const SHORT_URL_META = [
|
||||
'shortCode' => 'def456',
|
||||
@@ -52,6 +68,7 @@ class ListShortUrlsTest extends ApiTestCase
|
||||
'validUntil' => null,
|
||||
'maxVisits' => null,
|
||||
],
|
||||
'domain' => null,
|
||||
];
|
||||
private const SHORT_URL_CUSTOM_SLUG = [
|
||||
'shortCode' => 'custom',
|
||||
@@ -65,6 +82,7 @@ class ListShortUrlsTest extends ApiTestCase
|
||||
'validUntil' => null,
|
||||
'maxVisits' => 2,
|
||||
],
|
||||
'domain' => null,
|
||||
];
|
||||
private const SHORT_URL_CUSTOM_DOMAIN = [
|
||||
'shortCode' => 'ghi789',
|
||||
@@ -80,6 +98,7 @@ class ListShortUrlsTest extends ApiTestCase
|
||||
'validUntil' => null,
|
||||
'maxVisits' => null,
|
||||
],
|
||||
'domain' => 'example.com',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -104,6 +123,7 @@ class ListShortUrlsTest extends ApiTestCase
|
||||
{
|
||||
yield [[], [
|
||||
self::SHORT_URL_SHLINK,
|
||||
self::SHORT_URL_DOCS,
|
||||
self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN,
|
||||
self::SHORT_URL_META,
|
||||
self::SHORT_URL_CUSTOM_SLUG,
|
||||
@@ -114,8 +134,17 @@ class ListShortUrlsTest extends ApiTestCase
|
||||
self::SHORT_URL_CUSTOM_SLUG,
|
||||
self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN,
|
||||
self::SHORT_URL_META,
|
||||
self::SHORT_URL_DOCS,
|
||||
self::SHORT_URL_CUSTOM_DOMAIN,
|
||||
]];
|
||||
yield [['orderBy' => ['shortCode' => 'DESC']], [
|
||||
self::SHORT_URL_DOCS,
|
||||
self::SHORT_URL_CUSTOM_DOMAIN,
|
||||
self::SHORT_URL_META,
|
||||
self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN,
|
||||
self::SHORT_URL_CUSTOM_SLUG,
|
||||
self::SHORT_URL_SHLINK,
|
||||
]];
|
||||
yield [['startDate' => Chronos::parse('2018-12-01')->toAtomString()], [
|
||||
self::SHORT_URL_META,
|
||||
self::SHORT_URL_CUSTOM_SLUG,
|
||||
@@ -123,6 +152,7 @@ class ListShortUrlsTest extends ApiTestCase
|
||||
]];
|
||||
yield [['endDate' => Chronos::parse('2018-12-01')->toAtomString()], [
|
||||
self::SHORT_URL_SHLINK,
|
||||
self::SHORT_URL_DOCS,
|
||||
self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN,
|
||||
]];
|
||||
yield [['tags' => ['foo']], [
|
||||
@@ -139,6 +169,9 @@ class ListShortUrlsTest extends ApiTestCase
|
||||
self::SHORT_URL_META,
|
||||
self::SHORT_URL_CUSTOM_DOMAIN,
|
||||
]];
|
||||
yield [['searchTerm' => 'example.com'], [
|
||||
self::SHORT_URL_CUSTOM_DOMAIN,
|
||||
]];
|
||||
}
|
||||
|
||||
private function buildPagination(int $itemsCount): array
|
||||
|
||||
@@ -4,16 +4,55 @@ declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioApiTest\Shlink\Rest\Action;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use GuzzleHttp\RequestOptions;
|
||||
use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase;
|
||||
use ShlinkioApiTest\Shlink\Rest\Utils\NotFoundUrlHelpersTrait;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
class ResolveShortUrlActionTest extends ApiTestCase
|
||||
{
|
||||
/** @test */
|
||||
public function tryingToResolveInvalidUrlReturnsNotFoundError(): void
|
||||
{
|
||||
$expectedDetail = 'No URL found with short code "invalid"';
|
||||
use NotFoundUrlHelpersTrait;
|
||||
|
||||
$resp = $this->callApiWithKey(self::METHOD_GET, '/short-urls/invalid');
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideDisabledMeta
|
||||
*/
|
||||
public function shortUrlIsProperlyResolvedEvenWhenNotEnabled(array $disabledMeta): void
|
||||
{
|
||||
$shortCode = 'abc123';
|
||||
$url = sprintf('/short-urls/%s', $shortCode);
|
||||
$this->callShortUrl($shortCode);
|
||||
|
||||
$editResp = $this->callApiWithKey(self::METHOD_PATCH, $url, [RequestOptions::JSON => $disabledMeta]);
|
||||
$visitResp = $this->callShortUrl($shortCode);
|
||||
$fetchResp = $this->callApiWithKey(self::METHOD_GET, $url);
|
||||
|
||||
$this->assertEquals(self::STATUS_NO_CONTENT, $editResp->getStatusCode());
|
||||
$this->assertEquals(self::STATUS_NOT_FOUND, $visitResp->getStatusCode());
|
||||
$this->assertEquals(self::STATUS_OK, $fetchResp->getStatusCode());
|
||||
}
|
||||
|
||||
public function provideDisabledMeta(): iterable
|
||||
{
|
||||
$now = Chronos::now();
|
||||
|
||||
yield 'future validSince' => [['validSince' => $now->addMonth()->toAtomString()]];
|
||||
yield 'past validUntil' => [['validUntil' => $now->subMonth()->toAtomString()]];
|
||||
yield 'maxVisits reached' => [['maxVisits' => 1]];
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideInvalidUrls
|
||||
*/
|
||||
public function tryingToResolveInvalidUrlReturnsNotFoundError(
|
||||
string $shortCode,
|
||||
?string $domain,
|
||||
string $expectedDetail
|
||||
): void {
|
||||
$resp = $this->callApiWithKey(self::METHOD_GET, $this->buildShortUrlPath($shortCode, $domain));
|
||||
$payload = $this->getJsonResponsePayload($resp);
|
||||
|
||||
$this->assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode());
|
||||
@@ -21,6 +60,7 @@ class ResolveShortUrlActionTest extends ApiTestCase
|
||||
$this->assertEquals('INVALID_SHORTCODE', $payload['type']);
|
||||
$this->assertEquals($expectedDetail, $payload['detail']);
|
||||
$this->assertEquals('Short URL not found', $payload['title']);
|
||||
$this->assertEquals('invalid', $payload['shortCode']);
|
||||
$this->assertEquals($shortCode, $payload['shortCode']);
|
||||
$this->assertEquals($domain, $payload['domain'] ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,32 +20,38 @@ class ShortUrlsFixture extends AbstractFixture
|
||||
public function load(ObjectManager $manager): void
|
||||
{
|
||||
$abcShortUrl = $this->setShortUrlDate(
|
||||
new ShortUrl('https://shlink.io', ShortUrlMeta::createFromRawData(['customSlug' => 'abc123'])),
|
||||
new ShortUrl('https://shlink.io', ShortUrlMeta::fromRawData(['customSlug' => 'abc123'])),
|
||||
'2018-05-01',
|
||||
);
|
||||
$manager->persist($abcShortUrl);
|
||||
|
||||
$defShortUrl = $this->setShortUrlDate(new ShortUrl(
|
||||
'https://blog.alejandrocelaya.com/2017/12/09/acmailer-7-0-the-most-important-release-in-a-long-time/',
|
||||
ShortUrlMeta::createFromParams(Chronos::parse('2020-05-01'), null, 'def456'),
|
||||
ShortUrlMeta::fromRawData(['validSince' => Chronos::parse('2020-05-01'), 'customSlug' => 'def456']),
|
||||
), '2019-01-01 00:00:10');
|
||||
$manager->persist($defShortUrl);
|
||||
|
||||
$customShortUrl = $this->setShortUrlDate(new ShortUrl(
|
||||
'https://shlink.io',
|
||||
ShortUrlMeta::createFromParams(null, null, 'custom', 2),
|
||||
ShortUrlMeta::fromRawData(['customSlug' => 'custom', 'maxVisits' => 2]),
|
||||
), '2019-01-01 00:00:20');
|
||||
$manager->persist($customShortUrl);
|
||||
|
||||
$withDomainShortUrl = $this->setShortUrlDate(new ShortUrl(
|
||||
$ghiShortUrl = $this->setShortUrlDate(
|
||||
new ShortUrl('https://shlink.io/documentation/', ShortUrlMeta::fromRawData(['customSlug' => 'ghi789'])),
|
||||
'2018-05-01',
|
||||
);
|
||||
$manager->persist($ghiShortUrl);
|
||||
|
||||
$withDomainDuplicatingShortCode = $this->setShortUrlDate(new ShortUrl(
|
||||
'https://blog.alejandrocelaya.com/2019/04/27/considerations-to-properly-use-open-source-software-projects/',
|
||||
ShortUrlMeta::createFromRawData(['domain' => 'example.com', 'customSlug' => 'ghi789']),
|
||||
ShortUrlMeta::fromRawData(['domain' => 'example.com', 'customSlug' => 'ghi789']),
|
||||
), '2019-01-01 00:00:30');
|
||||
$manager->persist($withDomainShortUrl);
|
||||
$manager->persist($withDomainDuplicatingShortCode);
|
||||
|
||||
$withDomainAndSlugShortUrl = $this->setShortUrlDate(new ShortUrl(
|
||||
'https://google.com',
|
||||
ShortUrlMeta::createFromRawData(['domain' => 'some-domain.com', 'customSlug' => 'custom-with-domain']),
|
||||
ShortUrlMeta::fromRawData(['domain' => 'some-domain.com', 'customSlug' => 'custom-with-domain']),
|
||||
), '2018-10-20');
|
||||
$manager->persist($withDomainAndSlugShortUrl);
|
||||
|
||||
@@ -53,6 +59,7 @@ class ShortUrlsFixture extends AbstractFixture
|
||||
|
||||
$this->addReference('abc123_short_url', $abcShortUrl);
|
||||
$this->addReference('def456_short_url', $defShortUrl);
|
||||
$this->addReference('ghi789_short_url', $ghiShortUrl);
|
||||
}
|
||||
|
||||
private function setShortUrlDate(ShortUrl $shortUrl, string $date): ShortUrl
|
||||
|
||||
@@ -31,6 +31,11 @@ class VisitsFixture extends AbstractFixture implements DependentFixtureInterface
|
||||
$manager->persist(new Visit($defShortUrl, new Visitor('shlink-tests-agent', '', '127.0.0.1')));
|
||||
$manager->persist(new Visit($defShortUrl, new Visitor('shlink-tests-agent', 'https://app.shlink.io', '')));
|
||||
|
||||
/** @var ShortUrl $defShortUrl */
|
||||
$defShortUrl = $this->getReference('ghi789_short_url');
|
||||
$manager->persist(new Visit($defShortUrl, new Visitor('shlink-tests-agent', '', '1.2.3.4')));
|
||||
$manager->persist(new Visit($defShortUrl, new Visitor('shlink-tests-agent', 'https://app.shlink.io', '')));
|
||||
|
||||
$manager->flush();
|
||||
}
|
||||
}
|
||||
|
||||
38
module/Rest/test-api/Utils/NotFoundUrlHelpersTrait.php
Normal file
38
module/Rest/test-api/Utils/NotFoundUrlHelpersTrait.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioApiTest\Shlink\Rest\Utils;
|
||||
|
||||
use Laminas\Diactoros\Uri;
|
||||
|
||||
use function GuzzleHttp\Psr7\build_query;
|
||||
use function sprintf;
|
||||
|
||||
trait NotFoundUrlHelpersTrait
|
||||
{
|
||||
public function provideInvalidUrls(): iterable
|
||||
{
|
||||
yield 'invalid shortcode' => ['invalid', null, 'No URL found with short code "invalid"'];
|
||||
yield 'invalid shortcode without domain' => [
|
||||
'abc123',
|
||||
'example.com',
|
||||
'No URL found with short code "abc123" for domain "example.com"',
|
||||
];
|
||||
yield 'invalid shortcode + domain' => [
|
||||
'custom-with-domain',
|
||||
'example.com',
|
||||
'No URL found with short code "custom-with-domain" for domain "example.com"',
|
||||
];
|
||||
}
|
||||
|
||||
public function buildShortUrlPath(string $shortCode, ?string $domain, string $suffix = ''): string
|
||||
{
|
||||
$url = new Uri(sprintf('/short-urls/%s%s', $shortCode, $suffix));
|
||||
if ($domain !== null) {
|
||||
$url = $url->withQuery(build_query(['domain' => $domain]));
|
||||
}
|
||||
|
||||
return (string) $url;
|
||||
}
|
||||
}
|
||||
@@ -76,7 +76,7 @@ class CreateShortUrlActionTest extends TestCase
|
||||
];
|
||||
|
||||
yield [['longUrl' => 'http://www.domain.com/foo/bar'], ShortUrlMeta::createEmpty()];
|
||||
yield [$fullMeta, ShortUrlMeta::createFromRawData($fullMeta)];
|
||||
yield [$fullMeta, ShortUrlMeta::fromRawData($fullMeta)];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,6 +9,7 @@ use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Exception\ValidationException;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Service\ShortUrlService;
|
||||
use Shlinkio\Shlink\Rest\Action\ShortUrl\EditShortUrlTagsAction;
|
||||
|
||||
@@ -34,8 +35,8 @@ class EditShortUrlTagsActionTest extends TestCase
|
||||
public function tagsListIsReturnedIfCorrectShortCodeIsProvided(): void
|
||||
{
|
||||
$shortCode = 'abc123';
|
||||
$this->shortUrlService->setTagsByShortCode($shortCode, [])->willReturn(new ShortUrl(''))
|
||||
->shouldBeCalledOnce();
|
||||
$this->shortUrlService->setTagsByShortCode(new ShortUrlIdentifier($shortCode), [])->willReturn(new ShortUrl(''))
|
||||
->shouldBeCalledOnce();
|
||||
|
||||
$response = $this->action->handle(
|
||||
(new ServerRequest())->withAttribute('shortCode', 'abc123')
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user