Compare commits

...

69 Commits

Author SHA1 Message Date
Alejandro Celaya
e60d80bb16 Merge pull request #655 from shlinkio/develop
Release v2.0.5
2020-02-09 18:07:55 +01:00
Alejandro Celaya
1d4bea68af Updated changelog 2020-02-09 17:59:01 +01:00
Alejandro Celaya
d2f9f5fd5e Merge pull request #654 from acelaya-forks/feature/domain-docs
Feature/domain docs
2020-02-08 19:02:39 +01:00
Alejandro Celaya
f13c3364eb Updated changelog 2020-02-08 18:52:48 +01:00
Alejandro Celaya
ac04bedead Documented how Shlink behaves when using multiple domains 2020-02-08 18:52:02 +01:00
Alejandro Celaya
67a66cefa6 Merge pull request #653 from acelaya-forks/feature/remove-default-domain-from-body
Feature/remove default domain from body
2020-02-08 12:38:20 +01:00
Alejandro Celaya
43db066cb4 Updated changelog 2020-02-08 12:31:25 +01:00
Alejandro Celaya
faec758fba Added test to ensure default domain is ignored if provided when creatin a short URL 2020-02-08 12:30:47 +01:00
Alejandro Celaya
ccec6e03aa Updated middleware which drops default domain so that it is capable of doing it from parsed body too 2020-02-08 12:22:07 +01:00
Alejandro Celaya
3f08b38558 Merge pull request #652 from acelaya-forks/feature/fix-logs-permissions
Feature/fix logs permissions
2020-02-08 11:58:09 +01:00
Alejandro Celaya
1ee5f64738 Updated changelog 2020-02-08 11:51:39 +01:00
Alejandro Celaya
d22169803f Ensured any user can write in generated log files 2020-02-08 11:50:25 +01:00
Alejandro Celaya
57807c4360 Merge pull request #647 from shlinkio/develop
Release v2.0.4
2020-02-02 20:23:09 +01:00
Alejandro Celaya
6e1d07b0cc Merge pull request #646 from acelaya-forks/feature/search-on-domains
Feature/search on domains
2020-02-02 20:14:27 +01:00
Alejandro Celaya
0c0349fa39 Fixed version on changelog 2020-02-02 20:09:30 +01:00
Alejandro Celaya
8d8a0f2484 Updated changelog 2020-02-02 20:08:22 +01:00
Alejandro Celaya
8ff913aaf2 Ensured search terms are applied to the domain too 2020-02-02 20:07:19 +01:00
Alejandro Celaya
f7d54abb2b Merge pull request #645 from acelaya-forks/feature/multi-domain-fixes
Feature/multi domain fixes
2020-02-02 19:28:21 +01:00
Alejandro Celaya
ce990c67e3 Fixed coding styles 2020-02-02 19:19:35 +01:00
Alejandro Celaya
907b8453c6 Updated changelog 2020-02-02 19:16:53 +01:00
Alejandro Celaya
8a0ba11f79 Added one more test case for not found URLs on API tests 2020-02-02 19:15:14 +01:00
Alejandro Celaya
0c1ecd3caa Created DropDefaultDomainFromQueryMiddlewareTest 2020-02-02 19:13:32 +01:00
Alejandro Celaya
c07c37f7bd Created middleware to drop domain from query when it is the default one 2020-02-02 19:03:43 +01:00
Alejandro Celaya
fe652c67f4 Covered with API tests getting invalid short URLs by short code and domain 2020-02-02 13:15:08 +01:00
Alejandro Celaya
297985cf01 Ensured trying to fetch a short URL for any operation through the API results in 404 if it does not match with porovided domain 2020-02-02 12:58:26 +01:00
Alejandro Celaya
10f79ec01d Created new repository method which will look for short URLs without doing domain fallback 2020-02-02 12:44:35 +01:00
Alejandro Celaya
e87d4d61bc Added API test for editing tags with and without domain 2020-02-02 10:53:49 +01:00
Alejandro Celaya
e58f2a384e Added API test for visits with and without domain 2020-02-02 10:46:38 +01:00
Alejandro Celaya
881002634a Added API tests for short URL deletion with domain 2020-02-02 10:28:10 +01:00
Alejandro Celaya
aa80c2bb82 Updated API tests so that fixture short URLs are created with matching short codes and different domains 2020-02-02 09:51:17 +01:00
Alejandro Celaya
75cd9774b7 Added optional domain query param to documentation for all rest endpoints that need it 2020-02-02 09:15:43 +01:00
Alejandro Celaya
1a8e4cdfd7 Exposed domain on short URLs 2020-02-02 08:57:04 +01:00
Alejandro Celaya
6858dc4785 Updated setting short URL tags so that it accepts providing the domain 2020-02-01 22:59:21 +01:00
Alejandro Celaya
5d1d9dcac3 Allowed domain to be provided when editing short URL meta 2020-02-01 22:54:21 +01:00
Alejandro Celaya
732bb06c62 Updated short URL deletion so that it accepts the domain 2020-02-01 18:06:50 +01:00
Alejandro Celaya
5f00d8b732 Added domain flag to GetVisitsCommand 2020-02-01 17:56:43 +01:00
Alejandro Celaya
a3ff545d43 Improved VisitsRepositoryTest to cover fetching visits for URL with domain 2020-02-01 17:44:37 +01:00
Alejandro Celaya
279bd12a2d Ensured domain can be passed when fetching visits for a short URL 2020-02-01 17:34:16 +01:00
Alejandro Celaya
1b2a0d674f Fixed correct short URL being tracked when domain exists 2020-02-01 13:03:48 +01:00
Alejandro Celaya
fd82de31c0 Fixed the way ShortUrlIdentifier is created from requests, on different request scopes 2020-02-01 12:54:10 +01:00
Alejandro Celaya
327d35fe57 Created DTO used to transfer props needed to uniquely identify a short URL 2020-02-01 11:47:01 +01:00
Alejandro Celaya
e18187f04e Merge pull request #636 from acelaya-forks/feature/postgres-schema-support
Feature/postgres schema support
2020-01-29 11:02:23 +01:00
Alejandro Celaya
bd2f488e2c Updated entity mappings so that schema an table prefixes can be eventually provided 2020-01-29 10:53:06 +01:00
Alejandro Celaya
96350c8b8f Updated entities mapping config so that they return a function 2020-01-29 10:06:42 +01:00
Alejandro Celaya
a737eed5c5 Merge pull request #634 from acelaya-forks/feature/simplify-error-logs
Updated to shlink-common 2.6
2020-01-28 18:23:37 +01:00
Alejandro Celaya
9b2ccaeb7b Updated to shlink-common 2.6 2020-01-28 18:11:39 +01:00
Alejandro Celaya
304979273f Merge pull request #633 from acelaya-forks/feature/list-filtering-dto
Feature/list filtering dto
2020-01-28 13:05:24 +01:00
Alejandro Celaya
7add41d560 Ensured BC on dates for short urls params 2020-01-28 12:57:21 +01:00
Alejandro Celaya
51ebe57ac8 Updated changelog 2020-01-28 12:12:50 +01:00
Alejandro Celaya
6ff5a532ea Added extra API test covering complex order by for short URL lists 2020-01-28 11:20:48 +01:00
Alejandro Celaya
fccd92497a Added last check on ShortUrlsOrdering which makes sure everything keeps behaving as it used to 2020-01-28 11:17:54 +01:00
Alejandro Celaya
452bfea088 Created DTOs with implicit validation to wrap short URLs lists params 2020-01-28 10:49:55 +01:00
Alejandro Celaya
240d2588f9 Extracted some private functions ase helper global functions 2020-01-28 09:41:48 +01:00
Alejandro Celaya
eca7800487 Merge pull request #632 from shlinkio/develop
Release v2.0.3
2020-01-27 11:44:43 +01:00
Alejandro Celaya
b9e58b9300 Merge pull request #631 from acelaya-forks/feature/permission-denied
Feature/permission denied
2020-01-27 11:37:37 +01:00
Alejandro Celaya
54918db9ef Updated changelog 2020-01-27 11:31:44 +01:00
Alejandro Celaya
b07a603456 Updated dependencies 2020-01-27 11:30:29 +01:00
Alejandro Celaya
4fb2c64fa8 Merge pull request #630 from acelaya-forks/feature/fetch-not-visitable-url
Feature/fetch not visitable url
2020-01-26 20:00:47 +01:00
Alejandro Celaya
258c4102be Updated changelog 2020-01-26 19:55:03 +01:00
Alejandro Celaya
b9c7f8e8d4 Added unit tests for ShortUrlresolver 2020-01-26 19:53:18 +01:00
Alejandro Celaya
f32e7cc7c4 Removed tests checking domain logic from ShortUrlRepositoryTest 2020-01-26 19:25:41 +01:00
Alejandro Celaya
4ebd48b2b0 Created new service to resolve short URLs 2020-01-26 19:21:51 +01:00
Alejandro Celaya
f71bd84a20 Merge pull request #629 from acelaya-forks/feature/reset-meta
Feature/reset meta
2020-01-26 09:49:36 +01:00
Alejandro Celaya
33b45eb620 Updated changelog 2020-01-26 09:37:43 +01:00
Alejandro Celaya
1f9a912c04 Added API tests covering the edition of short URL meta with resetted values 2020-01-26 09:29:04 +01:00
Alejandro Celaya
45151cdde6 Standardized how the ShortUrlMeta object is created by exposing a single named constructor 2020-01-26 08:42:51 +01:00
Alejandro Celaya
8ca45eb388 Merge pull request #627 from acelaya-forks/feature/remote-ip-order
Feature/remote ip order
2020-01-24 21:28:39 +01:00
Alejandro Celaya
b7a34a6640 Updated changelog 2020-01-24 21:21:13 +01:00
Alejandro Celaya
8ec686f4e2 Updated order in which headers for remote IP detection are inspected 2020-01-24 21:19:40 +01:00
105 changed files with 1988 additions and 914 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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",

View File

@@ -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',
],

View File

@@ -9,6 +9,7 @@ return [
'entity_manager' => [
'orm' => [
'proxies_dir' => 'data/proxies',
'load_mappings_using_functional_style' => true,
],
'connection' => [
'user' => '',

View File

@@ -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',
],
];

View File

@@ -41,6 +41,7 @@ return [
'level' => Logger::INFO,
'filename' => 'data/log/shlink_log.log',
'max_files' => 30,
'file_permission' => 0666,
],
'formatter' => $formatter,
],

View File

@@ -1,6 +1,6 @@
# Shlink Docker image
[![Docker build status](https://img.shields.io/docker/cloud/build/shlinkio/shlink.svg?style=flat-square)](https://hub.docker.com/r/shlinkio/shlink/)
[![Docker build status](https://img.shields.io/docker/build/shlinkio/shlink.svg?style=flat-square)](https://hub.docker.com/r/shlinkio/shlink/)
[![Docker pulls](https://img.shields.io/docker/pulls/shlinkio/shlink.svg?style=flat-square)](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.

View File

@@ -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."
}
}
}

View 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"
}
}

View File

@@ -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
}
}
},

View File

@@ -72,7 +72,8 @@
"validSince": "2017-01-21T00:00:00+02:00",
"validUntil": null,
"maxVisits": 100
}
},
"domain": null
},
"text/plain": "https://doma.in/abc123"
}

View File

@@ -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": [

View File

@@ -18,6 +18,9 @@
"schema": {
"type": "string"
}
},
{
"$ref": "../parameters/domain.json"
}
],
"requestBody": {

View File

@@ -19,6 +19,9 @@
"type": "string"
}
},
{
"$ref": "../parameters/domain.json"
},
{
"name": "startDate",
"in": "query",

View File

@@ -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],

View File

@@ -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()));
}
}

View File

@@ -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([

View File

@@ -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();

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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']);

View File

@@ -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, '')),

View File

@@ -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);

View File

@@ -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]);

View File

@@ -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],
],

View File

@@ -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();
};

View File

@@ -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');
};

View File

@@ -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();
};

View File

@@ -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();
};

View File

@@ -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();
};

View File

@@ -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);
}

View File

@@ -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));

View File

@@ -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(''));

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -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));

View 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;
}
}

View File

@@ -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

View 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;
}
}

View 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;
}
}

View File

@@ -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;

View File

@@ -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(),
);
}
}

View File

@@ -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(),
);
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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'))

View File

@@ -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;
}

View File

@@ -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(),

View File

@@ -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;
}

View File

@@ -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;
}
}

View 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;
}
}

View File

@@ -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;
}

View File

@@ -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();

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View File

@@ -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());

View File

@@ -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;
}

View File

@@ -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(),
];
}

View File

@@ -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

View 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);
}
}

View File

@@ -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'));
}
}

View File

@@ -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];
}
}

View File

@@ -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();

View File

@@ -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(

View File

@@ -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);

View File

@@ -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' => [

View File

@@ -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());

View File

@@ -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());

View File

@@ -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()];
}
}

View File

@@ -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());
}
}

View 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;
})()];
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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());
}
}

View File

@@ -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(),

View File

@@ -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'],
],
];

View File

@@ -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();
};

View File

@@ -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(),

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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()]);
}
}

View File

@@ -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,
);
}
}

View File

@@ -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));
}
}

View File

@@ -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),

View File

@@ -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;
}
}

View File

@@ -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

View File

@@ -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());
}
}

View File

@@ -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/'];
}
}

View File

@@ -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']);
}
}

View File

@@ -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];
}
}

View File

@@ -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

View File

@@ -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);
}
}

View File

@@ -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

View File

@@ -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();
}
}

View 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;
}
}

View File

@@ -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)];
}
/**

View File

@@ -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