Compare commits

...

76 Commits

Author SHA1 Message Date
Alejandro Celaya
30297ac5ac Merge pull request #176 from acelaya/feature/1.10.2
feature/1.10.2
2018-08-04 16:51:01 +02:00
Alejandro Celaya
416c56dee2 Added new spanish translations 2018-08-04 16:37:54 +02:00
Alejandro Celaya
6b968a6843 Updated changelog including v1.10.2 2018-08-04 16:28:12 +02:00
Alejandro Celaya
080965e166 Improved ShortUrlRepositoryTest covering listing case with filter by tag and search term at the same time 2018-08-04 16:21:01 +02:00
Alejandro Celaya
c7239aaca2 Fixed duplicated join with same table performed while filtering short codes by search term and tags 2018-08-04 16:15:09 +02:00
Alejandro Celaya
110e8cb78d Added test to cover new IP resolution API limits 2018-08-04 15:50:02 +02:00
Alejandro Celaya
ed859767a8 Updated IpLocation resolver to be able to provide limits in order to apply sleeps 2018-08-02 23:02:48 +02:00
Alejandro Celaya
d54b823c88 Merge branch 'develop' 2018-08-02 17:56:38 +02:00
Alejandro Celaya
0ae44d3331 Merge pull request #168 from acelaya/feature/1.10.1
Feature/1.10.1
2018-08-02 17:55:25 +02:00
Alejandro Celaya
8063e643a3 Updated changelog with version 1.10.1 2018-08-01 20:58:48 +02:00
Alejandro Celaya
3883ed15c4 Fixed short codes DB length too short 2018-08-01 20:40:24 +02:00
Alejandro Celaya
a79c1f580e Fixed visits count multiplied by the number of tags when ordering and filtering by text 2018-08-01 20:31:54 +02:00
Alejandro Celaya
f4b569c245 Improved code 2018-08-01 20:28:05 +02:00
Alejandro Celaya
899771cc2e Fixed geolocation by switching to different API 2018-07-31 20:24:13 +02:00
Alejandro Celaya
863803b614 Fixed tests failing with new typehints 2018-07-31 19:59:41 +02:00
Alejandro Celaya
5be5e0bc60 Fixed coding styles 2018-07-31 19:53:59 +02:00
Alejandro Celaya
0b8e305533 Improved error management in process visits command 2018-07-31 19:42:33 +02:00
Alejandro Celaya
39d79366a3 Documented date range params for visits endpoint 2018-07-30 20:28:41 +02:00
Alejandro Celaya
d5b78f2a7e Fixed date fields not properly parsed depending if originally they were datetimes or strings 2018-07-28 18:57:24 +02:00
Alejandro Celaya
b2a63f734a Simplified how built shlink version is found out 2018-07-26 20:35:02 +02:00
Alejandro Celaya
82f41de87b Added build step which sets shlink's version 2018-07-26 18:44:04 +02:00
Alejandro Celaya
af4c66d40a Added version placeholder in place of hardcoded version in config 2018-07-26 18:42:53 +02:00
Alejandro Celaya
975260f126 Merge pull request #164 from shlinkio/develop
Develop
2018-07-09 18:48:28 +02:00
Alejandro Celaya
bd678b41f7 Merge pull request #163 from acelaya/develop
v1.10.0
2018-07-09 18:41:28 +02:00
Alejandro Celaya
66898b6ddc Added v1.10.0 to changelog 2018-07-09 18:40:48 +02:00
Alejandro Celaya
5eee683978 Added release datesto changelog 2018-07-09 17:48:23 +02:00
Alejandro Celaya
e92446df9b Updated changelog to use the keepachangelog format 2018-07-09 17:30:25 +02:00
Alejandro Celaya
63a69b05a1 Added Zend expressive swoole config provider to global config when present 2018-07-04 20:40:38 +02:00
Alejandro Celaya
c79ca1d13c Fixed phpstan issues 2018-07-04 20:28:05 +02:00
Alejandro Celaya
87c4851d7e Simplified ListKeysCommand reducing cyclomatic complexity on nested callbacks 2018-07-04 20:24:13 +02:00
Alejandro Celaya
a125c93ca3 Updated to phpstan 0.10 and infection 0.9 2018-07-04 12:08:27 +02:00
Alejandro Celaya
a8465094c1 Merge branch 'develop' 2018-06-18 20:49:32 +02:00
Alejandro Celaya
16f7359ac6 Merge pull request #156 from acelaya/feature/1.9.1
Improved paginator properties
2018-06-18 20:48:41 +02:00
Alejandro Celaya
f9f4817ee2 Aded v1.9.1 to changelog 2018-06-18 20:40:50 +02:00
Alejandro Celaya
c7e49f223f Fixed filtered lists not being properly paginated 2018-06-18 20:38:25 +02:00
Alejandro Celaya
6e79b4ba7b Fixed php binary used in child commands while installkation not properly inherited 2018-06-18 20:14:51 +02:00
Alejandro Celaya
f78a7f12a9 Improved paginator properties 2018-06-17 18:29:40 +02:00
Alejandro Celaya
b3664597b0 Merge branch 'develop' 2018-05-07 11:27:13 +02:00
Alejandro Celaya
8cfb4f61ca Merge pull request #148 from acelaya/feature/1.9.0
Version 1.9.0
2018-05-07 11:26:27 +02:00
Alejandro Celaya
b0dbb2dae4 Updated CreateShortCodeContentNegotiationMiddleware so that query parameter takes precedence over Accept header 2018-05-07 11:17:10 +02:00
Alejandro Celaya
7c6da4985d Updated build script to delete more development-specific files 2018-05-07 11:09:32 +02:00
Alejandro Celaya
386b0dfb7b Updated changelog 2018-05-07 11:03:28 +02:00
Alejandro Celaya
1437ff48ce Ensured all core actions log errors 2018-05-07 10:58:49 +02:00
Alejandro Celaya
63294f20ee Updated language files 2018-05-06 12:36:07 +02:00
Alejandro Celaya
d8acc3c247 Removed unused use statement 2018-05-06 12:34:21 +02:00
Alejandro Celaya
52d8ffa212 Improved CreateShortCodeContentNegotiationMiddleware sho that it takes into account the case in which an error is returned from next middleware 2018-05-06 12:28:22 +02:00
Alejandro Celaya
98ad2816e8 Documented new endpoint to create short URLs in a single step 2018-05-06 12:19:08 +02:00
Alejandro Celaya
9d890f4227 Created CreateShortCodeContentNegotiationMiddleware 2018-05-03 19:04:40 +02:00
Alejandro Celaya
0932d04907 Fixed tests namespaces to match their subject under test 2018-05-03 18:34:45 +02:00
Alejandro Celaya
1f78b5c524 Improved CreateShortCodeContentNegotiationMiddleware so that it can determine the format based on a query partameter 2018-05-03 18:32:32 +02:00
Alejandro Celaya
59f10619ba Created middleware used with short codes creation actions to handle content negotiation 2018-05-03 18:26:31 +02:00
Alejandro Celaya
334710e92c Added middleware which injects the content-length header in the response if not present 2018-05-03 18:25:57 +02:00
Alejandro Celaya
75b8175824 Fixed coding styles in config file 2018-05-03 18:05:16 +02:00
Alejandro Celaya
8a74ef2a33 Moved action to subnamespace 2018-05-03 18:04:00 +02:00
Alejandro Celaya
d05ac5ce9d Moved action to subnamespace 2018-05-03 18:03:10 +02:00
Alejandro Celaya
3100fffa2b Moved action to subnamespace 2018-05-03 18:02:45 +02:00
Alejandro Celaya
6bbacb1017 Moved action to subnamespace 2018-05-03 18:01:57 +02:00
Alejandro Celaya
4403dc5df9 Moved action to subnamespace 2018-05-03 18:00:32 +02:00
Alejandro Celaya
fdc637c23d Moved action to subnamespace 2018-05-03 17:59:28 +02:00
Alejandro Celaya
b99d662417 Created SingleStepCreateShortCodeActionTest 2018-05-03 17:57:43 +02:00
Alejandro Celaya
eb9a964c66 Removed unused use statement 2018-05-03 13:34:13 +02:00
Alejandro Celaya
e5ef8d7f8c Created action which allows short URLs to be created on a single API request 2018-05-03 13:21:43 +02:00
Alejandro Celaya
28650aee2b Fixed case sensitivity errors 2018-05-03 12:19:51 +02:00
Alejandro Celaya
a2294704e6 Split try catch to prevent undefined variables 2018-05-01 19:38:44 +02:00
Alejandro Celaya
e5e1aa2ff4 Defined abstract action which handles short codes generations 2018-05-01 19:35:12 +02:00
Alejandro Celaya
2f5290b9d3 Moved whitelisted routes in CheckAuthenticationMiddleware to external configuration 2018-05-01 18:36:42 +02:00
Alejandro Celaya
ef3c4aadf2 Moved most of rest routes config to their actions 2018-05-01 18:28:37 +02:00
Alejandro Celaya
c9ce56eea5 Added public method in AbstractRestAction which builds route definition 2018-05-01 18:16:44 +02:00
Alejandro Celaya
4fee656f96 Prepared version 1.9.0 2018-05-01 10:10:19 +02:00
Alejandro Celaya
d2a04259f5 Merge branch 'develop' 2018-04-07 09:06:45 +02:00
Alejandro Celaya
e504daa1ba Merge pull request #142 from acelaya/develop
Develop
2018-04-07 09:05:56 +02:00
Alejandro Celaya
8793a67ce9 Reduced the number of includes by pointing to dcotrine scripts with extension 2018-04-07 08:37:41 +02:00
Alejandro Celaya
b4ded374e9 Updated changelog 2018-04-07 08:32:06 +02:00
Alejandro Celaya
91d350b12f Removed path workaround in PathVersionMiddleware and simplified code 2018-04-07 08:31:03 +02:00
Alejandro Celaya
b3e25f28fd Added v1.8.1 to changelog 2018-04-07 08:25:01 +02:00
Alejandro Celaya
aca89f9abe Updated links to doctrine CLI scripts to avoid depending on symlinks 2018-04-07 08:21:34 +02:00
87 changed files with 1910 additions and 591 deletions

View File

@@ -1,243 +1,638 @@
## CHANGELOG
# CHANGELOG
### 1.8.1
## 1.10.2 - 2018-08-04
**Features**
#### Added
* [125: Implement a path which returns a 1px image instead of a redirection](https://github.com/shlinkio/shlink/issues/125)
* *Nothing*
**Enhancements:**
#### Changed
* [130: Update to Expressive 3](https://github.com/shlinkio/shlink/issues/130)
* [137: Update symfony packages to v4](https://github.com/shlinkio/shlink/issues/137)
* *Nothing*
**Tasks**
#### Deprecated
* [131: Drop support for PHP 7](https://github.com/shlinkio/shlink/issues/131)
* [132: Add infection to improve tests](https://github.com/shlinkio/shlink/issues/132)
* *Nothing*
### 1.7.2
#### Removed
**Bugs:**
* *Nothing*
* [135: Fix PathVersionMiddleware being ignored when using expressive 2.2](https://github.com/shlinkio/shlink/issues/135)
#### Fixed
### 1.7.1
* [#177](https://github.com/shlinkio/shlink/issues/177) Fixed `[GET] /short-codes` endpoint returning a 500 status code when trying to filter by `tags` and `searchTerm` at the same time.
* [#175](https://github.com/shlinkio/shlink/issues/175) Fixed error introduced in previous version, where you could end up banned from the service used to resolve IP address locations.
**Enhancements:**
In order to fix that, just fill [this form](http://ip-api.com/docs/unban) including your server's IP address and your server should be unbanned.
In order to prevent this, after resolving 150 IP addresses, shlink now waits 1 minute before trying to resolve any more addresses.
* [128: Upgrade to expressive 2.2](https://github.com/shlinkio/shlink/issues/128)
**Bugs**
## 1.10.1 - 2018-08-02
* [126: Expressive 2.2 causes failures by triggering E_USER_DEPRECATED errors](https://github.com/shlinkio/shlink/issues/126)
#### Added
### 1.7.0
* *Nothing*
**Features**
#### Changed
* [88: Allow to disable tracking of the short URL by including a configurable query param](https://github.com/shlinkio/shlink/issues/88)
* [108: Allow to edit metadata in created shortcodes](https://github.com/shlinkio/shlink/issues/108)
* [#167](https://github.com/shlinkio/shlink/issues/167) Shlink version is now set at build time to avoid older version numbers to be kept in newer builds.
**Enhancements:**
#### Deprecated
* [113: Update CLI commands to use SymfonyStyle](https://github.com/shlinkio/shlink/issues/113)
* [112: Configure cli commands lazy loading](https://github.com/shlinkio/shlink/issues/112)
* *Nothing*
**Tasks**
#### Removed
* [117: Make every module which throws exceptions have its own ExceptionInterface, and make them all extend Throwable](https://github.com/shlinkio/shlink/issues/117)
* [115: Add phpstan to build matrix on PHP >=7.1 envs](https://github.com/shlinkio/shlink/issues/115)
* [114: Replace vlucas/phpdotenv dev requirement by symfony/env](https://github.com/shlinkio/shlink/issues/114)
* *Nothing*
### 1.6.2
#### Fixed
**Bugs**
* [#165](https://github.com/shlinkio/shlink/issues/165) Fixed custom slugs failing when they are longer than 10 characters.
* [#166](https://github.com/shlinkio/shlink/issues/166) Fixed unusual edge case in which visits were not properly counted when ordering by visit and filtering by search term in `[GET] /short-codes` API endpoint.
* [#174](https://github.com/shlinkio/shlink/issues/174) Fixed geolocation not working due to a deprecation on used service.
* [#172](https://github.com/shlinkio/shlink/issues/172) Documented missing filtering params for `[GET] /short-codes/{shortCode}/visits` API endpoint, which allow the list to be filtered by date range.
* [109: Fix installation error due to typo in latest migration](https://github.com/shlinkio/shlink/issues/109)
For example: `https://doma.in/rest/v1/short-urls/abc123/visits?startDate=2017-05-23&endDate=2017-10-05`
### 1.6.1
* [#169](https://github.com/shlinkio/shlink/issues/169) Fixed unhandled error when parsing `ShortUrlMeta` and date fields are already `DateTime` instances.
**Tasks**
* [110: Create gitattributes file to define files to be excluded from distributable package](https://github.com/shlinkio/shlink/issues/110)
## 1.10.0 - 2018-07-09
### 1.6.0
#### Added
**Features**
* [#161](https://github.com/shlinkio/shlink/issues/161) AddED support for shlink to be run with [swoole](https://www.swoole.co.uk/) via [zend-expressive-swoole](https://github.com/zendframework/zend-expressive-swoole) package
* [44: Consider allowing to set custom slugs instead of generating a short code](https://github.com/shlinkio/shlink/issues/44)
* [47: Allow to limit short codes availability by date range](https://github.com/shlinkio/shlink/issues/47)
* [48: Allow to limit the number of visits to a short code](https://github.com/shlinkio/shlink/issues/48)
* [105: Added option to enable/disable URL validation by response status code.](https://github.com/shlinkio/shlink/pull/105)
#### Changed
**Enhancements:**
* [#159](https://github.com/shlinkio/shlink/issues/159) Updated CHANGELOG to follow the [keep-a-changelog](https://keepachangelog.com) format
* [#160](https://github.com/shlinkio/shlink/issues/160) Update infection to v0.9 and phpstan to v 0.10
* [27: Add repository functional tests with dbunit](https://github.com/shlinkio/shlink/issues/27)
* [86: Drop support for PHP 5](https://github.com/shlinkio/shlink/issues/86)
* [101: Make actions just capture very specific exceptions, and let the ErrorHandler catch any other exception](https://github.com/shlinkio/shlink/issues/101)
* [104: Use different templates for requested-short-code-does-not-exist and route-could-not-be-match](https://github.com/shlinkio/shlink/issues/104)
#### Deprecated
**Tasks**
* *Nothing*
* [99: Replace AnnotatedFactory by ConfigAbstractFactory](https://github.com/shlinkio/shlink/issues/99)
* [100: Replace twig by plates](https://github.com/shlinkio/shlink/issues/100)
* [102: Improve coding standards strictness](https://github.com/shlinkio/shlink/issues/102)
#### Removed
**Bugs**
* *Nothing*
* [103: Make NotFoundDelegate return proper content types based on accepted content](https://github.com/shlinkio/shlink/issues/103)
#### Fixed
### 1.5.0
* *Nothing*
**Enhancements:**
* [95: Add tags CRUD to CLI](https://github.com/shlinkio/shlink/issues/95)
* [59: Add tags CRUD to REST](https://github.com/shlinkio/shlink/issues/59)
* [66: Allow to import certain information from older app directory when updating](https://github.com/shlinkio/shlink/issues/66)
## 1.9.1 - 2018-06-18
**Tasks**
#### Added
* [96: Add namespace to functions](https://github.com/shlinkio/shlink/issues/96)
* [76: Add response examples to swagger docs](https://github.com/shlinkio/shlink/issues/76)
* [93: Improve cross domain management by using the ImplicitOptionsMiddleware](https://github.com/shlinkio/shlink/issues/93)
* [#155](https://github.com/shlinkio/shlink/issues/155) Improved the pagination object returned in lists, including more meaningful properties.
* Old structure:
```json
{
"pagination": {
"currentPage": 1,
"pagesCount": 2
}
}
```
* New structure:
```json
{
"pagination": {
"currentPage": 2,
"pagesCount": 13,
"itemsPerPage": 10,
"itemsInCurrentPage": 10,
"totalItems": 126
}
}
```
**Bugs**
#### Changed
* [92: Fix formatted dates, using an ISO compliant format](https://github.com/shlinkio/shlink/issues/92)
* *Nothing*
### 1.4.0
#### Deprecated
**Enhancements:**
* *Nothing*
* [89: Update to expressive 2](https://github.com/shlinkio/shlink/issues/89)
#### Removed
### 1.3.1
* *Nothing*
**Tasks**
#### Fixed
* [82: Enable FastRoute routes cache](https://github.com/shlinkio/shlink/issues/82)
* [85: Update year in license file](https://github.com/shlinkio/shlink/issues/85)
* [81: Add docker containers config](https://github.com/shlinkio/shlink/issues/81)
* [#154](https://github.com/shlinkio/shlink/issues/154) Fixed sizes of every result page when filtering by searchTerm
* [#157](https://github.com/shlinkio/shlink/issues/157) Background commands executed by installation process now respect the originally used php binary
**Bugs**
* [83: Short codes list: search in tags when filtering by query string](https://github.com/shlinkio/shlink/issues/83)
* [79: Increase the number of followed redirects](https://github.com/shlinkio/shlink/issues/79)
* [75: Apply PathVersionMiddleware only to rest routes defining it by configuration instead of code](https://github.com/shlinkio/shlink/issues/75)
* [77: Allow defining database server hostname and port](https://github.com/shlinkio/shlink/issues/77)
## 1.9.0 - 2018-05-07
### 1.3.0
#### Added
**Enhancements:**
* [#147](https://github.com/shlinkio/shlink/issues/147) Allowed short URLs to be created on the fly using a single API request, including the API key in a query param.
* [67: Allow to order the short codes list](https://github.com/shlinkio/shlink/issues/67)
* [60: Accept JSON requests in REST and use a body parser middleware to set the parsedBody](https://github.com/shlinkio/shlink/issues/60)
* [72: When listing API keys from CLI, display in yellow color enabled keys that have expired](https://github.com/shlinkio/shlink/issues/72)
* [58: Allow to filter short URLs by tag](https://github.com/shlinkio/shlink/issues/58)
* [69: Allow to filter short codes by text query](https://github.com/shlinkio/shlink/issues/69)
This eases integration with third party services.
With this feature, a simple request to a URL like `https://doma.in/rest/v1/short-codes/shorten?apiKey=[YOUR_API_KEY]&longUrl=[URL_TO_BE_SHORTENED]` would return the shortened one in JSON or plain text format.
**Tasks**
#### Changed
* [73: Tag endpoints in swagger file](https://github.com/shlinkio/shlink/issues/73)
* [71: Separate swagger docs into multiple files](https://github.com/shlinkio/shlink/issues/71)
* [63: Add path versioning to REST API routes](https://github.com/shlinkio/shlink/issues/63)
* *Nothing*
### 1.2.2
#### Deprecated
**Bugs**
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#139](https://github.com/shlinkio/shlink/issues/139) Ensured all core actions log exceptions
## 1.8.1 - 2018-04-07
#### Added
* *Nothing*
#### Changed
* [#141](https://github.com/shlinkio/shlink/issues/141) Removed workaround used in `PathVersionMiddleware`, since the bug in zend-stratigility has been fixed.
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#140](https://github.com/shlinkio/shlink/issues/140) Fixed warning thrown during installation while trying to include doctrine script
## 1.8.0 - 2018-03-29
#### Added
* [#125](https://github.com/shlinkio/shlink/issues/125) Implemented a path which returns a 1px image instead of a redirection.
Useful to track emails. Just add an image pointing to a URL like `https://doma.in/abc123/track` to any email and an invisible image will be generated tracking every time the email is opened.
* [#132](https://github.com/shlinkio/shlink/issues/132) Added infection to improve tests
#### Changed
* [#130](https://github.com/shlinkio/shlink/issues/130) Updated to Expressive 3
* [#137](https://github.com/shlinkio/shlink/issues/137) Updated symfony components to v4
#### Deprecated
* *Nothing*
#### Removed
* [#131](https://github.com/shlinkio/shlink/issues/131) Dropped support for PHP 7
#### Fixed
* *Nothing*
## 1.7.2 - 2018-03-26
#### Added
* *Nothing*
#### Changed
* *Nothing*
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#135](https://github.com/shlinkio/shlink/issues/135) Fixed `PathVersionMiddleware` being ignored when using expressive 2.2
## 1.7.1 - 2018-03-21
#### Added
* *Nothing*
#### Changed
* [#128](https://github.com/shlinkio/shlink/issues/128) Upgraded to expressive 2.2
This will ease the upcoming update to expressive 3
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#126](https://github.com/shlinkio/shlink/issues/126) Fixed `E_USER_DEPRECATED` errors triggered when using Expressive 2.2
## 1.7.0 - 2018-01-21
#### Added
* [#88](https://github.com/shlinkio/shlink/issues/88) Allowed tracking of short URLs to be disabled by including a configurable query param
* [#108](https://github.com/shlinkio/shlink/issues/108) Allowed metadata to be defined when creating short codes
#### Changed
* [#113](https://github.com/shlinkio/shlink/issues/113) Updated CLI commands to use `SymfonyStyle`
* [#112](https://github.com/shlinkio/shlink/issues/112) Enabled Lazy loading in CLI commands
* [#117](https://github.com/shlinkio/shlink/issues/117) Every module which throws exceptions has now its own `ExceptionInterface` extending `Throwable`
* [#115](https://github.com/shlinkio/shlink/issues/115) Added phpstan to build matrix on PHP >=7.1 envs
* [#114](https://github.com/shlinkio/shlink/issues/114) Replaced [vlucas/phpdotenv](https://github.com/vlucas/phpdotenv) dev requirement by [symfony/dotenv](https://github.com/symfony/dotenv)
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* *Nothing*
## 1.6.2 - 2017-10-25
#### Added
* *Nothing*
#### Changed
* *Nothing*
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#109](https://github.com/shlinkio/shlink/issues/109) Fixed installation error due to typo in latest migration
## 1.6.1 - 2017-10-24
#### Added
* *Nothing*
#### Changed
* [#110](https://github.com/shlinkio/shlink/issues/110) Created `.gitattributes` file to define files to be excluded from distributable package
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* *Nothing*
## 1.6.0 - 2017-10-23
#### Added
* [#44](https://github.com/shlinkio/shlink/issues/44) Now it is possible to set custom slugs for short URLs instead of using a generated short code
* [#47](https://github.com/shlinkio/shlink/issues/47) Allowed to limit short URLs availability by date range
* [#48](https://github.com/shlinkio/shlink/issues/48) Allowed to limit the number of visits to a short URL
* [#105](https://github.com/shlinkio/shlink/pull/105) Added option to enable/disable URL validation by response status code
#### Changed
* [#27](https://github.com/shlinkio/shlink/issues/27) Added repository functional tests with dbunit
* [#101](https://github.com/shlinkio/shlink/issues/101) Now specific actions just capture very specific exceptions, and let the `ErrorHandler` catch any other unhandled exception
* [#104](https://github.com/shlinkio/shlink/issues/104) Used different templates for *requested-short-code-does-not-exist* and *route-could-not-be-match*
* [#99](https://github.com/shlinkio/shlink/issues/99) Replaced usages of `AnnotatedFactory` by `ConfigAbstractFactory`
* [#100](https://github.com/shlinkio/shlink/issues/100) Updated templates engine. Replaced twig by plates
* [#102](https://github.com/shlinkio/shlink/issues/102) Improved coding standards strictness
#### Deprecated
* *Nothing*
#### Removed
* [#86](https://github.com/shlinkio/shlink/issues/86) Dropped support for PHP 5
#### Fixed
* [#103](https://github.com/shlinkio/shlink/issues/103) `NotFoundDelegate` now returns proper content types based on accepted content
## 1.5.0 - 2017-07-16
#### Added
* [#95](https://github.com/shlinkio/shlink/issues/95) Added tags CRUD to CLI
* [#59](https://github.com/shlinkio/shlink/issues/59) Added tags CRUD to REST
* [#66](https://github.com/shlinkio/shlink/issues/66) Allowed certain information to be imported from and older shlink instance directory when updating
#### Changed
* [#96](https://github.com/shlinkio/shlink/issues/96) Added namespace to functions
* [#76](https://github.com/shlinkio/shlink/issues/76) Added response examples to swagger docs
* [#93](https://github.com/shlinkio/shlink/issues/93) Improved cross domain management by using the `ImplicitOptionsMiddleware`
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#92](https://github.com/shlinkio/shlink/issues/92) Fixed formatted dates, using an ISO compliant format
## 1.4.0 - 2017-03-25
#### Added
* *Nothing*
#### Changed
* [#89](https://github.com/shlinkio/shlink/issues/89) Updated to expressive 2
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* *Nothing*
## 1.3.1 - 2017-01-22
#### Added
* *Nothing*
#### Changed
* [#82](https://github.com/shlinkio/shlink/issues/82) Enabled `FastRoute` routes cache
* [#85](https://github.com/shlinkio/shlink/issues/85) Updated year in license file
* [#81](https://github.com/shlinkio/shlink/issues/81) Added docker containers config
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#83](https://github.com/shlinkio/shlink/issues/83) Fixed short codes list: search in tags when filtering by query string
* [#79](https://github.com/shlinkio/shlink/issues/79) Increased the number of followed redirects
* [#75](https://github.com/shlinkio/shlink/issues/75) Applied `PathVersionMiddleware` only to rest routes defining it by configuration instead of code
* [#77](https://github.com/shlinkio/shlink/issues/77) Allowed defining database server hostname and port
## 1.3.0 - 2016-10-23
#### Added
* [#67](https://github.com/shlinkio/shlink/issues/67) Allowed to order the short codes list
* [#60](https://github.com/shlinkio/shlink/issues/60) Accepted JSON requests in REST and used a body parser middleware to set the request's `parsedBody`
* [#72](https://github.com/shlinkio/shlink/issues/72) When listing API keys from CLI, use yellow color for enabled keys that have expired
* [#58](https://github.com/shlinkio/shlink/issues/58) Allowed to filter short URLs by tag
* [#69](https://github.com/shlinkio/shlink/issues/69) Allowed to filter short URLs by text query
* [#73](https://github.com/shlinkio/shlink/issues/73) Added tag-related endpoints to swagger file
* [#63](https://github.com/shlinkio/shlink/issues/63) Added path versioning to REST API routes
#### Changed
* [#71](https://github.com/shlinkio/shlink/issues/71) Separated swagger docs into multiple files
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* *Nothing*
## 1.2.2 - 2016-08-29
#### Added
* *Nothing*
#### Changed
* *Nothing*
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* Fixed minor bugs on CORS requests
### 1.2.1
**Bugs**
## 1.2.1 - 2016-08-21
* [62: Fix cross-domain requests in REST API](https://github.com/shlinkio/shlink/issues/62)
#### Added
### 1.2.0
* *Nothing*
**Features**
#### Changed
* [45: Allow to define tags on short codes, to improve filtering and classification](https://github.com/shlinkio/shlink/issues/45)
* [7: Add website previews while listing available URLs](https://github.com/shlinkio/shlink/issues/7)
* *Nothing*
**Enhancements:**
#### Deprecated
* [57: Add database migrations system to improve updating between versions](https://github.com/shlinkio/shlink/issues/57)
* [31: Add support for other database management systems by improving the EntityManager factory](https://github.com/shlinkio/shlink/issues/31)
* [51: Generate build process to paquetize the app and ease distribution](https://github.com/shlinkio/shlink/issues/51)
* [38: Define installation script. It will request dynamic data on the fly so that there is no need to define env vars](https://github.com/shlinkio/shlink/issues/38)
* *Nothing*
**Tasks**
#### Removed
* [55: Create update script which does not try to create a new database](https://github.com/shlinkio/shlink/issues/55)
* [54: Add cache namespace to prevent name collisions with other apps in the same environment](https://github.com/shlinkio/shlink/issues/54)
* [29: Use the acelaya/ze-content-based-error-handler package instead of custom error handler implementation](https://github.com/shlinkio/shlink/issues/29)
* *Nothing*
**Bugs**
#### Fixed
* [53: Fix entities database interoperability](https://github.com/shlinkio/shlink/issues/53)
* [52: Add missing htaccess file for apache environments](https://github.com/shlinkio/shlink/issues/52)
* [#62](https://github.com/shlinkio/shlink/issues/62) Fixed cross-domain requests in REST API
### 1.1.0
**Features**
## 1.2.0 - 2016-08-21
* [46: Define a route that returns a QR code representing the shortened URL](https://github.com/shlinkio/shlink/issues/46)
#### Added
**Enhancements:**
* [#45](https://github.com/shlinkio/shlink/issues/45) Allowed to define tags on short codes, to improve filtering and classification
* [#7](https://github.com/shlinkio/shlink/issues/7) Added website previews while listing available URLs
* [#57](https://github.com/shlinkio/shlink/issues/57) Added database migrations system to improve updating between versions
* [#31](https://github.com/shlinkio/shlink/issues/31) Added support for other database management systems by improving the `EntityManager` factory
* [#51](https://github.com/shlinkio/shlink/issues/51) Generated build process to create app package and ease distribution
* [#38](https://github.com/shlinkio/shlink/issues/38) Defined installation script. It will request dynamic data on the fly so that there is no need to define env vars
* [#55](https://github.com/shlinkio/shlink/issues/55) Created update script which does not try to create a new database
* [32: Add support for other cache adapters by improving the Cache factory](https://github.com/shlinkio/shlink/issues/32)
* [14: https://github.com/shlinkio/shlink/issues/14](https://github.com/shlinkio/shlink/issues/14)
* [41: Cache the "short code" => "URL" map to prevent extra DB hits](https://github.com/shlinkio/shlink/issues/41)
* [13: Improve REST authentication](https://github.com/shlinkio/shlink/issues/13)
#### Changed
**Tasks**
* [#54](https://github.com/shlinkio/shlink/issues/54) Added cache namespace to prevent name collisions with other apps in the same environment
* [#29](https://github.com/shlinkio/shlink/issues/29) Used the [acelaya/ze-content-based-error-handler](https://github.com/acelaya/ze-content-based-error-handler) package instead of custom error handler implementation
* [39: Change copyright from "Alejandro Celaya" to "Shlink" in error pages](https://github.com/shlinkio/shlink/issues/39)
* [42: Make REST endpoints that need to find something return a 404 when "something" is not found](https://github.com/shlinkio/shlink/issues/42)
* [35: Make CLI commands to use the same PHP namespace as the one used for the command name](https://github.com/shlinkio/shlink/issues/35)
#### Deprecated
**Bugs**
* *Nothing*
* [40: Take into account the X-Forwarded-For header in order to get the visitor information, in case the server is behind a load balancer or proxy](https://github.com/shlinkio/shlink/issues/40)
#### Removed
### 1.0.0
* *Nothing*
**Enhancements:**
#### Fixed
* [33: Create a command to generate a short code charset by randomizing the default one](https://github.com/shlinkio/shlink/issues/33)
* [15: Return JSON/HTML responses for errors (4xx and 5xx) based on accept header (content negotiation)](https://github.com/shlinkio/shlink/issues/15)
* [23: Translate application literals](https://github.com/shlinkio/shlink/issues/23)
* [21: Allow to filter visits by date range](https://github.com/shlinkio/shlink/issues/21)
* [22: Save visits locations data on a visit_locations table](https://github.com/shlinkio/shlink/issues/22)
* [20: Inject cross domain headers in response only if the Origin header is present in the request](https://github.com/shlinkio/shlink/issues/20)
* [11: Separate code into multiple modules](https://github.com/shlinkio/shlink/issues/11)
* [18: Group routable middleware in an Action namespace](https://github.com/shlinkio/shlink/issues/18)
* [#53](https://github.com/shlinkio/shlink/issues/53) Fixed entities database interoperability
* [#52](https://github.com/shlinkio/shlink/issues/52) Added missing htaccess file for apache environments
**Tasks**
* [36: Remove hhvm from the CI matrix since it doesn't support array constants and will fail](https://github.com/shlinkio/shlink/issues/36)
* [4: Installation steps](https://github.com/shlinkio/shlink/issues/4)
* [6: Remove dependency on expressive helpers package](https://github.com/shlinkio/shlink/issues/6)
* [30: Replace the "services" first level config entry by "dependencies", in order to fulfill default Expressive name](https://github.com/shlinkio/shlink/issues/30)
* [12: Improve code coverage](https://github.com/shlinkio/shlink/issues/12)
* [25: Replace "Middleware" suffix on routable middlewares by "Action"](https://github.com/shlinkio/shlink/issues/25)
* [19: Update the vendor and app namespace from Acelaya\UrlShortener to Shlinkio\Shlink](https://github.com/shlinkio/shlink/issues/19)
## 1.1.0 - 2016-08-09
**Bugs**
#### Added
* [24: Prevent duplicated shortcodes errors because of the case insensitive behavior on MySQL](https://github.com/shlinkio/shlink/issues/24)
* [#46](https://github.com/shlinkio/shlink/issues/46) Defined a route that returns a QR code representing the shortened URL.
### 0.2.0
In order to get the QR code URL, use a pattern like `https://doma.in/abc123/qr-code`
**Enhancements:**
* [#32](https://github.com/shlinkio/shlink/issues/32) Added support for other cache adapters by improving the Cache factory
* [#14](https://github.com/shlinkio/shlink/issues/14) Added logger and enabled errors logging
* [#13](https://github.com/shlinkio/shlink/issues/13) Improved REST authentication
* [9: Use symfony/console to dispatch console requests, instead of trying to integrate the process with expressive](https://github.com/shlinkio/shlink/issues/9)
* [8: Create a REST API](https://github.com/shlinkio/shlink/issues/8)
* [10: Add more CLI functionality](https://github.com/shlinkio/shlink/issues/10)
#### Changed
**Tasks**
* [#41](https://github.com/shlinkio/shlink/issues/41) Cached the "short code" => "URL" map to prevent extra DB hits
* [#39](https://github.com/shlinkio/shlink/issues/39) Changed copyright from "Alejandro Celaya" to "Shlink" in error pages
* [#42](https://github.com/shlinkio/shlink/issues/42) REST endpoints that need to find *something* now return a 404 when it is not found
* [#35](https://github.com/shlinkio/shlink/issues/35) Updated CLI commands to use the same PHP namespace as the one used for the command name
* [5: Create CHANGELOG file](https://github.com/shlinkio/shlink/issues/5)
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#40](https://github.com/shlinkio/shlink/issues/40) Taken into account the `X-Forwarded-For` header in order to get the visitor information, in case the server is behind a load balancer or proxy
## 1.0.0 - 2016-08-01
#### Added
* [#33](https://github.com/shlinkio/shlink/issues/33) Created a command that generates a short code charset by randomizing the default one
* [#23](https://github.com/shlinkio/shlink/issues/23) Translated application literals
* [#21](https://github.com/shlinkio/shlink/issues/21) Allowed to filter visits by date range
* [#4](https://github.com/shlinkio/shlink/issues/4) Added installation steps
* [#12](https://github.com/shlinkio/shlink/issues/12) Improved code coverage
#### Changed
* [#15](https://github.com/shlinkio/shlink/issues/15) HTTP requests now return JSON/HTML responses for errors (4xx and 5xx) based on `Accept` header
* [#22](https://github.com/shlinkio/shlink/issues/22) Now visits locations data is saved on a `visit_locations` table
* [#20](https://github.com/shlinkio/shlink/issues/20) Injected cross domain headers in response only if the `Origin` header is present in the request
* [#11](https://github.com/shlinkio/shlink/issues/11) Separated code into multiple modules
* [#18](https://github.com/shlinkio/shlink/issues/18) Grouped routable middleware in an Action namespace
* [#6](https://github.com/shlinkio/shlink/issues/6) Project no longer depends on [zendframework/zend-expressive-helpers](https://github.com/zendframework/zend-expressive-helpers) package
* [#30](https://github.com/shlinkio/shlink/issues/30) Replaced the "services" first level config entry by "dependencies", in order to fulfill default Expressive naming
* [#25](https://github.com/shlinkio/shlink/issues/25) Replaced "Middleware" suffix on routable middlewares by "Action"
* [#19](https://github.com/shlinkio/shlink/issues/19) Changed the vendor and app namespace from `Acelaya\UrlShortener` to `Shlinkio\Shlink`
#### Deprecated
* *Nothing*
#### Removed
* [#36](https://github.com/shlinkio/shlink/issues/36) Removed hhvm from the CI matrix since it doesn't support array constants and will fail
#### Fixed
* [#24](https://github.com/shlinkio/shlink/issues/24) Prevented duplicated short codes errors because of the case insensitive behavior on MySQL
## 0.2.0 - 2016-08-01
#### Added
* [#8](https://github.com/shlinkio/shlink/issues/8) Created a REST API
* [#10](https://github.com/shlinkio/shlink/issues/10) Added more CLI functionality
* [#5](https://github.com/shlinkio/shlink/issues/5) Created a CHANGELOG file
#### Changed
* [#9](https://github.com/shlinkio/shlink/issues/9) Used [symfony/console](https://github.com/symfony/console) to dispatch console requests, instead of trying to integrate the process with expressive
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* *Nothing*

View File

@@ -33,8 +33,12 @@ rm composer.*
rm LICENSE
rm indocker
rm docker-compose.yml
rm docker-compose.override.yml
rm docker-compose.override.yml.dist
rm func_tests_bootstrap.php
rm php*
rm README.md
rm infection.json
rm -rf build
rm -ff data/database.sqlite
rm -rf data/infra
@@ -42,6 +46,9 @@ rm -rf data/{cache,log,proxies}/{*,.gitignore}
rm -rf config/params/{*,.gitignore}
rm -rf config/autoload/{{,*.}local.php{,.dist},.gitignore}
# Update shlink version in config
sed -i "s/%SHLINK_VERSION%/${version}/g" config/autoload/app_options.global.php
# Compressing file
rm -f "${projectdir}"/build/shlink_${version}_dist.zip
zip -ry "${projectdir}"/build/shlink_${version}_dist.zip "../shlink_${version}_dist"

View File

@@ -15,11 +15,7 @@
"php": "^7.1",
"acelaya/ze-content-based-error-handler": "^2.2",
"cocur/slugify": "^3.0",
"doctrine/annotations": "^1.4",
"doctrine/cache": "^1.6",
"doctrine/collections": "^1.4",
"doctrine/common": "^2.7",
"doctrine/dbal": "^2.5",
"doctrine/migrations": "^1.4",
"doctrine/orm": "^2.5",
"endroid/qr-code": "^1.7",
@@ -47,12 +43,12 @@
},
"require-dev": {
"filp/whoops": "^2.0",
"infection/infection": "^0.8.1",
"phpstan/phpstan": "0.9",
"infection/infection": "^0.9.0",
"phpstan/phpstan": "^0.10.0",
"phpunit/phpcov": "^5.0",
"phpunit/phpunit": "^7.0",
"slevomat/coding-standard": "^4.0",
"squizlabs/php_codesniffer": "^3.1 <3.2",
"squizlabs/php_codesniffer": "^3.2.3",
"symfony/dotenv": "^4.0",
"symfony/var-dumper": "^4.0",
"zendframework/zend-component-installer": "^2.1",
@@ -103,8 +99,8 @@
"phpcov merge build --html build/html"
],
"stan": "phpstan analyse module/*/src/ --level=6 -c phpstan.neon",
"infect": "infection --threads=4 --min-msi=65 --only-covered --log-verbosity=2",
"infect-show": "infection --threads=4 --min-msi=65 --only-covered --log-verbosity=2 --show-mutations",
"infect": "infection --threads=4 --min-msi=55 --only-covered --log-verbosity=2",
"infect-show": "infection --threads=4 --min-msi=55 --only-covered --log-verbosity=2 --show-mutations",
"expressive": "expressive"
},
"config": {

View File

@@ -7,7 +7,7 @@ return [
'app_options' => [
'name' => 'Shlink',
'version' => '1.7.0',
'version' => '%SHLINK_VERSION%',
'secret_key' => env('SECRET_KEY'),
],

View File

@@ -2,7 +2,7 @@
declare(strict_types=1);
use Shlinkio\Shlink\Common\Middleware\LocaleMiddleware;
use Shlinkio\Shlink\Core\Response\NotFoundDelegate;
use Shlinkio\Shlink\Core\Response\NotFoundHandler;
use Shlinkio\Shlink\Rest\Middleware\BodyParserMiddleware;
use Shlinkio\Shlink\Rest\Middleware\CheckAuthenticationMiddleware;
use Shlinkio\Shlink\Rest\Middleware\CrossDomainMiddleware;
@@ -16,12 +16,13 @@ return [
'pre-routing' => [
'middleware' => [
ErrorHandler::class,
Expressive\Helper\ContentLengthMiddleware::class,
LocaleMiddleware::class,
],
'priority' => 11,
],
'pre-routing-rest' => [
// 'path' => '/rest',
'path' => '/rest',
'middleware' => [
PathVersionMiddleware::class,
],
@@ -49,7 +50,7 @@ return [
'post-routing' => [
'middleware' => [
Expressive\Router\Middleware\DispatchMiddleware::class,
NotFoundDelegate::class,
NotFoundHandler::class,
],
'priority' => 1,
],

View File

@@ -24,6 +24,9 @@ return (new ConfigAggregator\ConfigAggregator([
Expressive\Router\FastRouteRouter\ConfigProvider::class,
Expressive\Plates\ConfigProvider::class,
Expressive\Helper\ConfigProvider::class,
\class_exists(Expressive\Swoole\ConfigProvider::class)
? Expressive\Swoole\ConfigProvider::class
: new ConfigAggregator\ArrayProvider([]),
ExpressiveErrorHandler\ConfigProvider::class,
Common\ConfigProvider::class,
Core\ConfigProvider::class,

0
data/infra/database/.gitignore vendored Normal file → Executable file
View File

0
data/infra/nginx/.gitignore vendored Normal file → Executable file
View File

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace ShlinkMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Schema\SchemaException;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20180801183328 extends AbstractMigration
{
private const NEW_SIZE = 255;
private const OLD_SIZE = 10;
/**
* @param Schema $schema
* @throws SchemaException
*/
public function up(Schema $schema): void
{
$this->setSize($schema, self::NEW_SIZE);
}
/**
* @param Schema $schema
* @throws SchemaException
*/
public function down(Schema $schema): void
{
$this->setSize($schema, self::OLD_SIZE);
}
/**
* @param Schema $schema
* @param int $size
* @throws SchemaException
*/
private function setSize(Schema $schema, int $size): void
{
$schema->getTable('short_urls')->getColumn('short_code')->setLength($size);
}
}

View File

@@ -3,11 +3,23 @@
"properties": {
"currentPage": {
"type": "integer",
"description": "The number of current page being displayed."
"description": "The number of current page."
},
"pagesCount": {
"type": "integer",
"description": "The total number of pages that can be displayed."
"description": "The total number of pages that can be obtained."
},
"itemsPerPage": {
"type": "integer",
"description": "The number of items for every page."
},
"itemsInCurrentPage": {
"type": "integer",
"description": "The number of items in current page (could be smaller than itemsPerPage)."
},
"totalItems": {
"type": "integer",
"description": "The total number of items among all pages."
}
}
}

View File

@@ -25,7 +25,7 @@
}
},
{
"name": "tags",
"name": "tags[]",
"in": "query",
"description": "A list of tags used to filter the resultset. Only short URLs tagged with at least one of the provided tags will be returned. (Since v1.3.0)",
"required": false,
@@ -116,7 +116,10 @@
],
"pagination": {
"currentPage": 5,
"pagesCount": 12
"pagesCount": 12,
"itemsPerPage": 10,
"itemsInCurrentPage": 10,
"totalItems": 115
}
}
}

View File

@@ -0,0 +1,125 @@
{
"get": {
"tags": [
"ShortCodes"
],
"summary": "Create a short URL",
"description": "Creates a short URL in a single API call. Useful for third party integrations",
"parameters": [
{
"name": "apiKey",
"in": "query",
"description": "The API key used to authenticate the request",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "longUrl",
"in": "query",
"description": "The URL to be shortened",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "format",
"in": "query",
"description": "The format in which you want the response to be returned. You can also use the \"Accept\" header instead of this",
"required": false,
"schema": {
"type": "string",
"enum": [
"txt",
"json"
]
}
}
],
"responses": {
"200": {
"description": "The list of short URLs",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"longUrl": {
"type": "string",
"description": "The original long URL that has been shortened"
},
"shortUrl": {
"type": "string",
"description": "The generated short URL"
},
"shortCode": {
"type": "string",
"description": "the short code that is being used in the short URL"
}
}
}
},
"text/plain": {
"schema": {
"type": "string"
}
}
},
"examples": {
"application/json": {
"longUrl": "https://github.com/shlinkio/shlink",
"shortUrl": "https://dom.ain/abc123",
"shortCode": "abc123"
},
"text/plain": "https://dom.ain/abc123"
}
},
"400": {
"description": "The long URL was not provided or is invalid.",
"content": {
"application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
},
"text/plain": {
"schema": {
"type": "string"
}
}
},
"examples": {
"application/json": {
"error": "INVALID_URL",
"message": "Provided URL foo is invalid. Try with a different one."
},
"text/plain": "INVALID_URL"
}
},
"500": {
"description": "Unexpected error.",
"content": {
"application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
},
"text/plain": {
"schema": {
"type": "string"
}
}
},
"examples": {
"application/json": {
"error": "UNKNOWN_ERROR",
"message": "Unexpected error occurred"
},
"text/plain": "UNKNOWN_ERROR"
}
}
}
}
}

View File

@@ -15,6 +15,24 @@
"schema": {
"type": "string"
}
},
{
"name": "startDate",
"in": "query",
"description": "The date (in ISO-8601 format) from which we want to get visits.",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "endDate",
"in": "query",
"description": "The date (in ISO-8601 format) until which we want to get visits.",
"required": false,
"schema": {
"type": "string"
}
}
],
"security": [

View File

@@ -40,6 +40,9 @@
"/v1/short-codes": {
"$ref": "paths/v1_short-codes.json"
},
"/v1/short-codes/shorten": {
"$ref": "paths/v1_short-codes_shorten.json"
},
"/v1/short-codes/{shortCode}": {
"$ref": "paths/v1_short-codes_{shortCode}.json"
},

View File

@@ -14,5 +14,9 @@
"tmpDir": "build/infection/temp",
"phpUnit": {
"configDir": "."
},
"mutators": {
"@default": true,
"IdenticalEqual": false
}
}

View File

@@ -3,7 +3,7 @@ declare(strict_types=1);
use Shlinkio\Shlink\CLI\Command;
use Shlinkio\Shlink\CLI\Factory\ApplicationFactory;
use Shlinkio\Shlink\Common\Service\IpLocationResolver;
use Shlinkio\Shlink\Common\Service\IpApiLocationResolver;
use Shlinkio\Shlink\Common\Service\PreviewGenerator;
use Shlinkio\Shlink\Core\Service;
use Shlinkio\Shlink\Rest\Service\ApiKeyService;
@@ -51,7 +51,7 @@ return [
],
Command\Visit\ProcessVisitsCommand::class => [
Service\VisitService::class,
IpLocationResolver::class,
IpApiLocationResolver::class,
'translator',
],
Command\Config\GenerateCharsetCommand::class => ['translator'],

Binary file not shown.

View File

@@ -1,15 +1,15 @@
msgid ""
msgstr ""
"Project-Id-Version: Shlink 1.0\n"
"POT-Creation-Date: 2018-01-21 09:36+0100\n"
"PO-Revision-Date: 2018-01-21 09:39+0100\n"
"POT-Creation-Date: 2018-08-04 16:35+0200\n"
"PO-Revision-Date: 2018-08-04 16:37+0200\n"
"Last-Translator: Alejandro Celaya <alejandro@alejandrocelaya.com>\n"
"Language-Team: \n"
"Language: es_ES\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: Poedit 2.0.4\n"
"X-Generator: Poedit 2.0.6\n"
"X-Poedit-Basepath: ..\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Poedit-SourceCharset: UTF-8\n"
@@ -317,6 +317,13 @@ msgstr "Ignorada IP de localhost"
msgid "Address located at \"%s\""
msgstr "Dirección localizada en \"%s\""
msgid "An error occurred while locating IP"
msgstr "Se produjo un error al localizar la IP"
#, php-format
msgid "IP location resolver limit reached. Waiting %s seconds..."
msgstr "Limite del localizador de IPs alcanzado. Esperando %s segundos..."
msgid "Finished processing all IPs"
msgstr "Finalizado el procesado de todas las IPs"

View File

@@ -14,7 +14,11 @@ use Zend\I18n\Translator\TranslatorInterface;
class ListKeysCommand extends Command
{
const NAME = 'api-key:list';
private const ERROR_STRING_PATTERN = '<fg=red>%s</>';
private const SUCCESS_STRING_PATTERN = '<info>%s</info>';
private const WARNING_STRING_PATTERN = '<comment>%s</comment>';
public const NAME = 'api-key:list';
/**
* @var ApiKeyServiceInterface
@@ -55,59 +59,32 @@ class ListKeysCommand extends Command
foreach ($list as $row) {
$key = $row->getKey();
$expiration = $row->getExpirationDate();
$formatMethod = $this->determineFormatMethod($row);
$messagePattern = $this->determineMessagePattern($row);
// Set columns for this row
$rowData = [$formatMethod($key)];
$rowData = [\sprintf($messagePattern, $key)];
if (! $enabledOnly) {
$rowData[] = $formatMethod($this->getEnabledSymbol($row));
$rowData[] = \sprintf($messagePattern, $this->getEnabledSymbol($row));
}
$rowData[] = $expiration !== null ? $expiration->format(\DateTime::ATOM) : '-';
$rows[] = $rowData;
}
$io->table(array_filter([
$io->table(\array_filter([
$this->translator->translate('Key'),
! $enabledOnly ? $this->translator->translate('Is enabled') : null,
$this->translator->translate('Expiration date'),
]), $rows);
}
private function determineFormatMethod(ApiKey $apiKey): callable
private function determineMessagePattern(ApiKey $apiKey): string
{
if (! $apiKey->isEnabled()) {
return [$this, 'getErrorString'];
return self::ERROR_STRING_PATTERN;
}
return $apiKey->isExpired() ? [$this, 'getWarningString'] : [$this, 'getSuccessString'];
}
/**
* @param string $value
* @return string
*/
private function getErrorString(string $value): string
{
return sprintf('<fg=red>%s</>', $value);
}
/**
* @param string $value
* @return string
*/
private function getSuccessString(string $value): string
{
return sprintf('<info>%s</info>', $value);
}
/**
* @param string $value
* @return string
*/
private function getWarningString(string $value): string
{
return sprintf('<comment>%s</comment>', $value);
return $apiKey->isExpired() ? self::WARNING_STRING_PATTERN : self::SUCCESS_STRING_PATTERN;
}
/**

View File

@@ -18,6 +18,7 @@ use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Filesystem\Exception\IOException;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Process\PhpExecutableFinder;
use Zend\Config\Writer\WriterInterface;
class InstallCommand extends Command
@@ -48,6 +49,14 @@ class InstallCommand extends Command
* @var bool
*/
private $isUpdate;
/**
* @var PhpExecutableFinder
*/
private $phpFinder;
/**
* @var string|bool
*/
private $phpBinary;
/**
* InstallCommand constructor.
@@ -55,22 +64,25 @@ class InstallCommand extends Command
* @param Filesystem $filesystem
* @param ConfigCustomizerManagerInterface $configCustomizers
* @param bool $isUpdate
* @param PhpExecutableFinder|null $phpFinder
* @throws LogicException
*/
public function __construct(
WriterInterface $configWriter,
Filesystem $filesystem,
ConfigCustomizerManagerInterface $configCustomizers,
$isUpdate = false
bool $isUpdate = false,
PhpExecutableFinder $phpFinder = null
) {
parent::__construct();
$this->configWriter = $configWriter;
$this->isUpdate = $isUpdate;
$this->filesystem = $filesystem;
$this->configCustomizers = $configCustomizers;
$this->phpFinder = $phpFinder ?: new PhpExecutableFinder();
}
public function configure()
protected function configure(): void
{
$this
->setName('shlink:install')
@@ -84,7 +96,7 @@ class InstallCommand extends Command
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
public function execute(InputInterface $input, OutputInterface $output)
protected function execute(InputInterface $input, OutputInterface $output): void
{
$this->io = new SymfonyStyle($input, $output);
@@ -133,8 +145,8 @@ class InstallCommand extends Command
// If current command is not update, generate database
if (! $this->isUpdate) {
$this->io->write('Initializing database...');
if (! $this->runCommand(
'php vendor/bin/doctrine orm:schema-tool:create',
if (! $this->runPhpCommand(
'vendor/doctrine/orm/bin/doctrine.php orm:schema-tool:create',
'Error generating database.',
$output
)) {
@@ -144,8 +156,8 @@ class InstallCommand extends Command
// Run database migrations
$this->io->write('Updating database...');
if (! $this->runCommand(
'php vendor/bin/doctrine-migrations migrations:migrate',
if (! $this->runPhpCommand(
'vendor/doctrine/migrations/bin/doctrine-migrations.php migrations:migrate',
'Error updating database.',
$output
)) {
@@ -154,8 +166,8 @@ class InstallCommand extends Command
// Generate proxies
$this->io->write('Generating proxies...');
if (! $this->runCommand(
'php vendor/bin/doctrine orm:generate-proxies',
if (! $this->runPhpCommand(
'vendor/doctrine/orm/bin/doctrine.php orm:generate-proxies',
'Error generating proxies.',
$output
)) {
@@ -213,23 +225,27 @@ class InstallCommand extends Command
* @throws LogicException
* @throws InvalidArgumentException
*/
private function runCommand($command, $errorMessage, OutputInterface $output): bool
private function runPhpCommand($command, $errorMessage, OutputInterface $output): bool
{
if ($this->processHelper === null) {
$this->processHelper = $this->getHelper('process');
}
$process = $this->processHelper->run($output, $command);
if ($this->phpBinary === null) {
$this->phpBinary = $this->phpFinder->find(false) ?: 'php';
}
$this->io->writeln('Running "' . sprintf('%s %s', $this->phpBinary, $command) . '"');
$process = $this->processHelper->run($output, sprintf('%s %s', $this->phpBinary, $command));
if ($process->isSuccessful()) {
$this->io->writeln(' <info>Success!</info>');
return true;
}
if ($this->io->isVerbose()) {
return false;
if (! $this->io->isVerbose()) {
$this->io->error($errorMessage . ' Run this command with -vvv to see specific error info.');
}
$this->io->error($errorMessage . ' Run this command with -vvv to see specific error info.');
return false;
}
}

View File

@@ -15,8 +15,8 @@ use Zend\I18n\Translator\TranslatorInterface;
class ProcessVisitsCommand extends Command
{
const LOCALHOST = '127.0.0.1';
const NAME = 'visit:process';
private const LOCALHOST = '127.0.0.1';
public const NAME = 'visit:process';
/**
* @var VisitServiceInterface
@@ -55,16 +55,18 @@ class ProcessVisitsCommand extends Command
$io = new SymfonyStyle($input, $output);
$visits = $this->visitService->getUnlocatedVisits();
$count = 0;
foreach ($visits as $visit) {
$ipAddr = $visit->getRemoteAddr();
$io->write(sprintf('%s <info>%s</info>', $this->translator->translate('Processing IP'), $ipAddr));
$io->write(\sprintf('%s <info>%s</info>', $this->translator->translate('Processing IP'), $ipAddr));
if ($ipAddr === self::LOCALHOST) {
$io->writeln(
sprintf(' (<comment>%s</comment>)', $this->translator->translate('Ignored localhost address'))
\sprintf(' (<comment>%s</comment>)', $this->translator->translate('Ignored localhost address'))
);
continue;
}
$count++;
try {
$result = $this->ipLocationResolver->resolveIpLocation($ipAddr);
@@ -73,12 +75,27 @@ class ProcessVisitsCommand extends Command
$visit->setVisitLocation($location);
$this->visitService->saveVisit($visit);
$io->writeln(sprintf(
$io->writeln(\sprintf(
' (' . $this->translator->translate('Address located at "%s"') . ')',
$location->getCityName()
));
} catch (WrongIpException $e) {
continue;
$io->writeln(
\sprintf(' <error>%s</error>', $this->translator->translate('An error occurred while locating IP'))
);
if ($io->isVerbose()) {
$this->getApplication()->renderException($e, $output);
}
}
if ($count === $this->ipLocationResolver->getApiLimit()) {
$count = 0;
$seconds = $this->ipLocationResolver->getApiInterval();
$io->note(\sprintf(
$this->translator->translate('IP location resolver limit reached. Waiting %s seconds...'),
$seconds
));
\sleep($seconds);
}
}

View File

@@ -7,6 +7,7 @@ use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Api\GenerateKeyCommand;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Service\ApiKeyService;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
@@ -37,7 +38,8 @@ class GenerateKeyCommandTest extends TestCase
*/
public function noExpirationDateIsDefinedIfNotProvided()
{
$this->apiKeyService->create(null)->shouldBeCalledTimes(1);
$this->apiKeyService->create(null)->shouldBeCalledTimes(1)
->willReturn(new ApiKey());
$this->commandTester->execute([
'command' => 'api-key:generate',
]);
@@ -46,9 +48,10 @@ class GenerateKeyCommandTest extends TestCase
/**
* @test
*/
public function expirationDateIsDefinedIfWhenProvided()
public function expirationDateIsDefinedIfProvided()
{
$this->apiKeyService->create(Argument::type(\DateTime::class))->shouldBeCalledTimes(1);
$this->apiKeyService->create(Argument::type(\DateTime::class))->shouldBeCalledTimes(1)
->willReturn(new ApiKey());
$this->commandTester->execute([
'command' => 'api-key:generate',
'--expirationDate' => '2016-01-01',

View File

@@ -15,6 +15,7 @@ use Symfony\Component\Console\Helper\ProcessHelper;
use Symfony\Component\Console\Tester\CommandTester;
use Symfony\Component\Filesystem\Exception\IOException;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Process\PhpExecutableFinder;
use Symfony\Component\Process\Process;
use Zend\Config\Writer\WriterInterface;
@@ -55,6 +56,9 @@ class InstallCommandTest extends TestCase
$configCustomizers = $this->prophesize(ConfigCustomizerManagerInterface::class);
$configCustomizers->get(Argument::cetera())->willReturn($configCustomizer->reveal());
$finder = $this->prophesize(PhpExecutableFinder::class);
$finder->find(false)->willReturn('php');
$app = new Application();
$helperSet = $app->getHelperSet();
$helperSet->set($processHelper->reveal());
@@ -62,7 +66,9 @@ class InstallCommandTest extends TestCase
$this->command = new InstallCommand(
$this->configWriter->reveal(),
$this->filesystem->reveal(),
$configCustomizers->reveal()
$configCustomizers->reveal(),
false,
$finder->reveal()
);
$app->add($this->command);

View File

@@ -3,6 +3,7 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Tag;
use Doctrine\Common\Collections\ArrayCollection;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\MethodProphecy;
use Prophecy\Prophecy\ObjectProphecy;
@@ -52,7 +53,7 @@ class CreateTagCommandTest extends TestCase
{
$tagNames = ['foo', 'bar'];
/** @var MethodProphecy $createTags */
$createTags = $this->tagService->createTags($tagNames)->willReturn([]);
$createTags = $this->tagService->createTags($tagNames)->willReturn(new ArrayCollection());
$this->commandTester->execute([
'--name' => $tagNames,

View File

@@ -7,7 +7,7 @@ use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Visit\ProcessVisitsCommand;
use Shlinkio\Shlink\Common\Service\IpLocationResolver;
use Shlinkio\Shlink\Common\Service\IpApiLocationResolver;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Service\VisitService;
use Symfony\Component\Console\Application;
@@ -32,7 +32,9 @@ class ProcessVisitsCommandTest extends TestCase
public function setUp()
{
$this->visitService = $this->prophesize(VisitService::class);
$this->ipResolver = $this->prophesize(IpLocationResolver::class);
$this->ipResolver = $this->prophesize(IpApiLocationResolver::class);
$this->ipResolver->getApiLimit()->willReturn(10000000000);
$command = new ProcessVisitsCommand(
$this->visitService->reveal(),
$this->ipResolver->reveal(),
@@ -95,4 +97,41 @@ class ProcessVisitsCommandTest extends TestCase
$output = $this->commandTester->getDisplay();
$this->assertTrue(strpos($output, 'Ignored localhost address') > 0);
}
/**
* @test
*/
public function sleepsEveryTimeTheApiLimitIsReached()
{
$visits = [
(new Visit())->setRemoteAddr('1.2.3.4'),
(new Visit())->setRemoteAddr('4.3.2.1'),
(new Visit())->setRemoteAddr('12.34.56.78'),
(new Visit())->setRemoteAddr('1.2.3.4'),
(new Visit())->setRemoteAddr('4.3.2.1'),
(new Visit())->setRemoteAddr('12.34.56.78'),
(new Visit())->setRemoteAddr('1.2.3.4'),
(new Visit())->setRemoteAddr('4.3.2.1'),
(new Visit())->setRemoteAddr('12.34.56.78'),
(new Visit())->setRemoteAddr('4.3.2.1'),
];
$apiLimit = 3;
$this->visitService->getUnlocatedVisits()->willReturn($visits);
$this->visitService->saveVisit(Argument::any())->will(function () {
});
$getApiLimit = $this->ipResolver->getApiLimit()->willReturn($apiLimit);
$getApiInterval = $this->ipResolver->getApiInterval()->willReturn(0);
$resolveIpLocation = $this->ipResolver->resolveIpLocation(Argument::any())->willReturn([])
->shouldBeCalledTimes(count($visits));
$this->commandTester->execute([
'command' => 'visit:process',
]);
$getApiLimit->shouldHaveBeenCalledTimes(\count($visits));
$getApiInterval->shouldHaveBeenCalledTimes(\round(\count($visits) / $apiLimit));
$resolveIpLocation->shouldHaveBeenCalledTimes(\count($visits));
}
}

View File

@@ -32,7 +32,7 @@ return [
Image\ImageBuilder::class => Image\ImageBuilderFactory::class,
Service\IpLocationResolver::class => ConfigAbstractFactory::class,
Service\IpApiLocationResolver::class => ConfigAbstractFactory::class,
Service\PreviewGenerator::class => ConfigAbstractFactory::class,
],
'aliases' => [
@@ -51,7 +51,7 @@ return [
ConfigAbstractFactory::class => [
TranslatorExtension::class => ['translator'],
LocaleMiddleware::class => ['translator'],
Service\IpLocationResolver::class => ['httpClient'],
Service\IpApiLocationResolver::class => ['httpClient'],
Service\PreviewGenerator::class => [
ImageBuilder::class,
Filesystem::class,

View File

@@ -23,8 +23,7 @@ class EntityManagerFactory implements FactoryInterface
* @param null|array $options
* @return object
* @throws ServiceNotFoundException if unable to resolve the service.
* @throws ServiceNotCreatedException if an exception is raised when
* creating a service.
* @throws ServiceNotCreatedException if an exception is raised when creating a service.
* @throws ContainerException if any other error occurs
*/
public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
@@ -32,14 +31,14 @@ class EntityManagerFactory implements FactoryInterface
$globalConfig = $container->get('config');
$isDevMode = isset($globalConfig['debug']) ? ((bool) $globalConfig['debug']) : false;
$cache = $container->has(Cache::class) ? $container->get(Cache::class) : new ArrayCache();
$emConfig = isset($globalConfig['entity_manager']) ? $globalConfig['entity_manager'] : [];
$connecitonConfig = isset($emConfig['connection']) ? $emConfig['connection'] : [];
$ormConfig = isset($emConfig['orm']) ? $emConfig['orm'] : [];
$emConfig = $globalConfig['entity_manager'] ?? [];
$connectionConfig = $emConfig['connection'] ?? [];
$ormConfig = $emConfig['orm'] ?? [];
return EntityManager::create($connecitonConfig, Setup::createAnnotationMetadataConfiguration(
isset($ormConfig['entities_paths']) ? $ormConfig['entities_paths'] : [],
return EntityManager::create($connectionConfig, Setup::createAnnotationMetadataConfiguration(
$ormConfig['entities_paths'] ?? [],
$isDevMode,
isset($ormConfig['proxies_dir']) ? $ormConfig['proxies_dir'] : null,
$ormConfig['proxies_dir'] ?? null,
$cache,
false
));

View File

@@ -8,7 +8,7 @@ use Zend\Paginator\Adapter\AdapterInterface;
class PaginableRepositoryAdapter implements AdapterInterface
{
const ITEMS_PER_PAGE = 10;
public const ITEMS_PER_PAGE = 10;
/**
* @var PaginableRepositoryInterface
@@ -34,7 +34,7 @@ class PaginableRepositoryAdapter implements AdapterInterface
$orderBy = null
) {
$this->paginableRepository = $paginableRepository;
$this->searchTerm = $searchTerm !== null ? trim(strip_tags($searchTerm)) : null;
$this->searchTerm = $searchTerm !== null ? \trim(\strip_tags($searchTerm)) : null;
$this->orderBy = $orderBy;
$this->tags = $tags;
}
@@ -46,7 +46,7 @@ class PaginableRepositoryAdapter implements AdapterInterface
* @param int $itemCountPerPage Number of items per page
* @return array
*/
public function getItems($offset, $itemCountPerPage)
public function getItems($offset, $itemCountPerPage): array
{
return $this->paginableRepository->findList(
$itemCountPerPage,
@@ -66,7 +66,7 @@ class PaginableRepositoryAdapter implements AdapterInterface
* The return value is cast to an integer.
* @since 5.1.0
*/
public function count()
public function count(): int
{
return $this->paginableRepository->countList($this->searchTerm, $this->tags);
}

View File

@@ -8,13 +8,16 @@ use Zend\Stdlib\ArrayUtils;
trait PaginatorUtilsTrait
{
protected function serializePaginator(Paginator $paginator)
protected function serializePaginator(Paginator $paginator): array
{
return [
'data' => ArrayUtils::iteratorToArray($paginator->getCurrentItems()),
'pagination' => [
'currentPage' => $paginator->getCurrentPageNumber(),
'pagesCount' => $paginator->count(),
'itemsPerPage' => $paginator->getItemCountPerPage(),
'itemsInCurrentPage' => $paginator->getCurrentItemCount(),
'totalItems' => $paginator->getTotalItemCount(),
],
];
}
@@ -25,7 +28,7 @@ trait PaginatorUtilsTrait
* @param Paginator $paginator
* @return bool
*/
protected function isLastPage(Paginator $paginator)
protected function isLastPage(Paginator $paginator): bool
{
return $paginator->getCurrentPageNumber() >= $paginator->count();
}

View File

@@ -26,7 +26,7 @@ class PixelResponse extends Response
private function createBody(): StreamInterface
{
$body = new Stream('php://temp', 'wb+');
$body->write(\base64_decode(self::BASE_64_IMAGE));
$body->write((string) \base64_decode(self::BASE_64_IMAGE));
$body->rewind();
return $body;
}

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Common\Service;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use Shlinkio\Shlink\Common\Exception\WrongIpException;
class IpApiLocationResolver implements IpLocationResolverInterface
{
private const SERVICE_PATTERN = 'http://ip-api.com/json/%s';
/**
* @var Client
*/
private $httpClient;
public function __construct(Client $httpClient)
{
$this->httpClient = $httpClient;
}
/**
* @param string $ipAddress
* @return array
* @throws WrongIpException
*/
public function resolveIpLocation(string $ipAddress): array
{
try {
$response = $this->httpClient->get(\sprintf(self::SERVICE_PATTERN, $ipAddress));
return $this->mapFields(\json_decode((string) $response->getBody(), true));
} catch (GuzzleException $e) {
throw WrongIpException::fromIpAddress($ipAddress, $e);
}
}
private function mapFields(array $entry): array
{
return [
'country_code' => $entry['countryCode'] ?? '',
'country_name' => $entry['country'] ?? '',
'region_name' => $entry['regionName'] ?? '',
'city' => $entry['city'] ?? '',
'latitude' => $entry['lat'] ?? '',
'longitude' => $entry['lon'] ?? '',
'time_zone' => $entry['timezone'] ?? '',
];
}
/**
* Returns the interval in seconds that needs to be waited when the API limit is reached
*
* @return int
*/
public function getApiInterval(): int
{
return 65; // ip-api interval is 1 minute. Return 5 extra seconds just in case
}
/**
* Returns the limit of requests that can be performed to the API in a specific interval, or null if no limit exists
*
* @return int|null
*/
public function getApiLimit(): ?int
{
return 145; // ip-api limit is 150 requests per minute. Leave 5 less requests just in case
}
}

View File

@@ -1,38 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Common\Service;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use Shlinkio\Shlink\Common\Exception\WrongIpException;
class IpLocationResolver implements IpLocationResolverInterface
{
const SERVICE_PATTERN = 'http://freegeoip.net/json/%s';
/**
* @var Client
*/
private $httpClient;
public function __construct(Client $httpClient)
{
$this->httpClient = $httpClient;
}
/**
* @param string $ipAddress
* @return array
* @throws WrongIpException
*/
public function resolveIpLocation(string $ipAddress): array
{
try {
$response = $this->httpClient->get(sprintf(self::SERVICE_PATTERN, $ipAddress));
return json_decode((string) $response->getBody(), true);
} catch (GuzzleException $e) {
throw WrongIpException::fromIpAddress($ipAddress, $e);
}
}
}

View File

@@ -13,4 +13,18 @@ interface IpLocationResolverInterface
* @throws WrongIpException
*/
public function resolveIpLocation(string $ipAddress): array;
/**
* Returns the interval in seconds that needs to be waited when the API limit is reached
*
* @return int
*/
public function getApiInterval(): int;
/**
* Returns the limit of requests that can be performed to the API in a specific interval, or null if no limit exists
*
* @return int|null
*/
public function getApiLimit(): ?int;
}

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Common\Service;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\TransferException;
use GuzzleHttp\Psr7\Response;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Common\Service\IpApiLocationResolver;
class IpApiLocationResolverTest extends TestCase
{
/**
* @var IpApiLocationResolver
*/
protected $ipResolver;
/**
* @var ObjectProphecy
*/
protected $client;
public function setUp()
{
$this->client = $this->prophesize(Client::class);
$this->ipResolver = new IpApiLocationResolver($this->client->reveal());
}
/**
* @test
*/
public function correctIpReturnsDecodedInfo()
{
$actual = [
'countryCode' => 'bar',
'lat' => 5,
'lon' => 10,
];
$expected = [
'country_code' => 'bar',
'country_name' => '',
'region_name' => '',
'city' => '',
'latitude' => 5,
'longitude' => 10,
'time_zone' => '',
];
$response = new Response();
$response->getBody()->write(\json_encode($actual));
$response->getBody()->rewind();
$this->client->get('http://ip-api.com/json/1.2.3.4')->willReturn($response)
->shouldBeCalledTimes(1);
$this->assertEquals($expected, $this->ipResolver->resolveIpLocation('1.2.3.4'));
}
/**
* @test
* @expectedException \Shlinkio\Shlink\Common\Exception\WrongIpException
*/
public function guzzleExceptionThrowsShlinkException()
{
$this->client->get('http://ip-api.com/json/1.2.3.4')->willThrow(new TransferException())
->shouldBeCalledTimes(1);
$this->ipResolver->resolveIpLocation('1.2.3.4');
}
/**
* @test
*/
public function getApiIntervalReturnsExpectedValue()
{
$this->assertEquals(65, $this->ipResolver->getApiInterval());
}
/**
* @test
*/
public function getApiLimitReturnsExpectedValue()
{
$this->assertEquals(145, $this->ipResolver->getApiLimit());
}
}

View File

@@ -1,58 +0,0 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Common\Service;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\TransferException;
use GuzzleHttp\Psr7\Response;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Common\Service\IpLocationResolver;
class IpLocationResolverTest extends TestCase
{
/**
* @var IpLocationResolver
*/
protected $ipResolver;
/**
* @var ObjectProphecy
*/
protected $client;
public function setUp()
{
$this->client = $this->prophesize(Client::class);
$this->ipResolver = new IpLocationResolver($this->client->reveal());
}
/**
* @test
*/
public function correctIpReturnsDecodedInfo()
{
$expected = [
'foo' => 'bar',
'baz' => 'foo',
];
$response = new Response();
$response->getBody()->write(json_encode($expected));
$response->getBody()->rewind();
$this->client->get('http://freegeoip.net/json/1.2.3.4')->willReturn($response)
->shouldBeCalledTimes(1);
$this->assertEquals($expected, $this->ipResolver->resolveIpLocation('1.2.3.4'));
}
/**
* @test
* @expectedException \Shlinkio\Shlink\Common\Exception\WrongIpException
*/
public function guzzleExceptionThrowsShlinkException()
{
$this->client->get('http://freegeoip.net/json/1.2.3.4')->willThrow(new TransferException())
->shouldBeCalledTimes(1);
$this->ipResolver->resolveIpLocation('1.2.3.4');
}
}

View File

@@ -6,7 +6,7 @@ use Shlinkio\Shlink\Common\Service\PreviewGenerator;
use Shlinkio\Shlink\Core\Action;
use Shlinkio\Shlink\Core\Middleware;
use Shlinkio\Shlink\Core\Options;
use Shlinkio\Shlink\Core\Response\NotFoundDelegate;
use Shlinkio\Shlink\Core\Response\NotFoundHandler;
use Shlinkio\Shlink\Core\Service;
use Zend\Expressive\Router\RouterInterface;
use Zend\Expressive\Template\TemplateRendererInterface;
@@ -17,7 +17,7 @@ return [
'dependencies' => [
'factories' => [
Options\AppOptions::class => Options\AppOptionsFactory::class,
NotFoundDelegate::class => ConfigAbstractFactory::class,
NotFoundHandler::class => ConfigAbstractFactory::class,
// Services
Service\UrlShortener::class => ConfigAbstractFactory::class,
@@ -33,14 +33,10 @@ return [
Action\PreviewAction::class => ConfigAbstractFactory::class,
Middleware\QrCodeCacheMiddleware::class => ConfigAbstractFactory::class,
],
'aliases' => [
'Zend\Expressive\Delegate\DefaultDelegate' => NotFoundDelegate::class,
],
],
ConfigAbstractFactory::class => [
NotFoundDelegate::class => [TemplateRendererInterface::class],
NotFoundHandler::class => [TemplateRendererInterface::class],
// Services
Service\UrlShortener::class => [
@@ -60,14 +56,16 @@ return [
Service\UrlShortener::class,
Service\VisitsTracker::class,
Options\AppOptions::class,
'Logger_Shlink',
],
Action\PixelAction::class => [
Service\UrlShortener::class,
Service\VisitsTracker::class,
Options\AppOptions::class,
'Logger_Shlink',
],
Action\QrCodeAction::class => [RouterInterface::class, Service\UrlShortener::class, 'Logger_Shlink'],
Action\PreviewAction::class => [PreviewGenerator::class, Service\UrlShortener::class],
Action\PreviewAction::class => [PreviewGenerator::class, Service\UrlShortener::class, 'Logger_Shlink'],
Middleware\QrCodeCacheMiddleware::class => [Cache::class],
],

View File

@@ -7,6 +7,8 @@ use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Shlinkio\Shlink\Core\Action\Util\ErrorResponseBuilderTrait;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
@@ -30,15 +32,21 @@ abstract class AbstractTrackingAction implements MiddlewareInterface
* @var AppOptions
*/
private $appOptions;
/**
* @var LoggerInterface
*/
private $logger;
public function __construct(
UrlShortenerInterface $urlShortener,
VisitsTrackerInterface $visitTracker,
AppOptions $appOptions
AppOptions $appOptions,
LoggerInterface $logger = null
) {
$this->urlShortener = $urlShortener;
$this->visitTracker = $visitTracker;
$this->appOptions = $appOptions;
$this->logger = $logger ?: new NullLogger();
}
/**
@@ -66,6 +74,7 @@ abstract class AbstractTrackingAction implements MiddlewareInterface
return $this->createResp($longUrl);
} catch (InvalidShortCodeException | EntityDoesNotExistException $e) {
$this->logger->warning('An error occurred while tracking short code.' . PHP_EOL . $e);
return $this->buildErrorResponse($request, $handler);
}
}

View File

@@ -7,6 +7,8 @@ use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Shlinkio\Shlink\Common\Exception\PreviewGenerationException;
use Shlinkio\Shlink\Common\Service\PreviewGeneratorInterface;
use Shlinkio\Shlink\Common\Util\ResponseUtilsTrait;
@@ -28,11 +30,19 @@ class PreviewAction implements MiddlewareInterface
* @var UrlShortenerInterface
*/
private $urlShortener;
/**
* @var LoggerInterface
*/
private $logger;
public function __construct(PreviewGeneratorInterface $previewGenerator, UrlShortenerInterface $urlShortener)
{
public function __construct(
PreviewGeneratorInterface $previewGenerator,
UrlShortenerInterface $urlShortener,
LoggerInterface $logger = null
) {
$this->previewGenerator = $previewGenerator;
$this->urlShortener = $urlShortener;
$this->logger = $logger ?: new NullLogger();
}
/**
@@ -53,6 +63,7 @@ class PreviewAction implements MiddlewareInterface
$imagePath = $this->previewGenerator->generatePreview($url);
return $this->generateImageResponse($imagePath);
} catch (InvalidShortCodeException | EntityDoesNotExistException | PreviewGenerationException $e) {
$this->logger->warning('An error occurred while generating preview image.' . PHP_EOL . $e);
return $this->buildErrorResponse($request, $handler);
}
}

View File

@@ -15,6 +15,7 @@ use Shlinkio\Shlink\Core\Action\Util\ErrorResponseBuilderTrait;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
use Zend\Expressive\Router\Exception\RuntimeException;
use Zend\Expressive\Router\RouterInterface;
class QrCodeAction implements MiddlewareInterface
@@ -52,6 +53,8 @@ class QrCodeAction implements MiddlewareInterface
* @param RequestHandlerInterface $handler
*
* @return Response
* @throws \InvalidArgumentException
* @throws RuntimeException
*/
public function process(Request $request, RequestHandlerInterface $handler): Response
{
@@ -59,11 +62,8 @@ class QrCodeAction implements MiddlewareInterface
$shortCode = $request->getAttribute('shortCode');
try {
$this->urlShortener->shortCodeToUrl($shortCode);
} catch (InvalidShortCodeException $e) {
$this->logger->warning('Tried to create a QR code with an invalid short code' . PHP_EOL . $e);
return $this->buildErrorResponse($request, $handler);
} catch (EntityDoesNotExistException $e) {
$this->logger->warning('Tried to create a QR code with a not found short code' . PHP_EOL . $e);
} catch (InvalidShortCodeException | EntityDoesNotExistException $e) {
$this->logger->warning('An error occurred while creating QR code' . PHP_EOL . $e);
return $this->buildErrorResponse($request, $handler);
}

View File

@@ -6,7 +6,7 @@ namespace Shlinkio\Shlink\Core\Action\Util;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Shlinkio\Shlink\Core\Response\NotFoundDelegate;
use Shlinkio\Shlink\Core\Response\NotFoundHandler;
trait ErrorResponseBuilderTrait
{
@@ -14,7 +14,7 @@ trait ErrorResponseBuilderTrait
ServerRequestInterface $request,
RequestHandlerInterface $handler
): ResponseInterface {
$request = $request->withAttribute(NotFoundDelegate::NOT_FOUND_TEMPLATE, 'ShlinkCore::invalid-short-code');
$request = $request->withAttribute(NotFoundHandler::NOT_FOUND_TEMPLATE, 'ShlinkCore::invalid-short-code');
return $handler->handle($request);
}
}

View File

@@ -29,7 +29,7 @@ class ShortUrl extends AbstractEntity implements \JsonSerializable
* name="short_code",
* type="string",
* nullable=false,
* length=10,
* length=255,
* unique=true
* )
*/

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Model;
use Psr\Http\Message\UriInterface;
final class CreateShortCodeData
{
/**
* @var UriInterface
*/
private $longUrl;
/**
* @var array
*/
private $tags;
/**
* @var ShortUrlMeta
*/
private $meta;
public function __construct(
UriInterface $longUrl,
array $tags = [],
ShortUrlMeta $meta = null
) {
$this->longUrl = $longUrl;
$this->tags = $tags;
$this->meta = $meta ?? ShortUrlMeta::createFromParams();
}
/**
* @return UriInterface
*/
public function getLongUrl(): UriInterface
{
return $this->longUrl;
}
/**
* @return array
*/
public function getTags(): array
{
return $this->tags;
}
/**
* @return ShortUrlMeta
*/
public function getMeta(): ShortUrlMeta
{
return $this->meta;
}
}

View File

@@ -71,26 +71,41 @@ final class ShortUrlMeta
* @param array $data
* @throws ValidationException
*/
private function validate(array $data)
private function validate(array $data): void
{
$inputFilter = new ShortUrlMetaInputFilter($data);
if (! $inputFilter->isValid()) {
throw ValidationException::fromInputFilter($inputFilter);
}
$this->validSince = $inputFilter->getValue(ShortUrlMetaInputFilter::VALID_SINCE);
$this->validSince = $this->validSince !== null ? new \DateTime($this->validSince) : null;
$this->validUntil = $inputFilter->getValue(ShortUrlMetaInputFilter::VALID_UNTIL);
$this->validUntil = $this->validUntil !== null ? new \DateTime($this->validUntil) : null;
$this->validSince = $this->parseDateField($inputFilter->getValue(ShortUrlMetaInputFilter::VALID_SINCE));
$this->validUntil = $this->parseDateField($inputFilter->getValue(ShortUrlMetaInputFilter::VALID_UNTIL));
$this->customSlug = $inputFilter->getValue(ShortUrlMetaInputFilter::CUSTOM_SLUG);
$this->maxVisits = $inputFilter->getValue(ShortUrlMetaInputFilter::MAX_VISITS);
$this->maxVisits = $this->maxVisits !== null ? (int) $this->maxVisits : null;
}
/**
* @param string|\DateTime|null $date
* @return \DateTime|null
*/
public function getValidSince()
private function parseDateField($date): ?\DateTime
{
if ($date === null || $date instanceof \DateTime) {
return $date;
}
if (\is_string($date)) {
return new \DateTime($date);
}
return null;
}
/**
* @return \DateTime|null
*/
public function getValidSince(): ?\DateTime
{
return $this->validSince;
}
@@ -103,7 +118,7 @@ final class ShortUrlMeta
/**
* @return \DateTime|null
*/
public function getValidUntil()
public function getValidUntil(): ?\DateTime
{
return $this->validUntil;
}

View File

@@ -25,7 +25,7 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
$orderBy = null
): array {
$qb = $this->createListQueryBuilder($searchTerm, $tags);
$qb->select('s');
$qb->select('DISTINCT s');
// Set limit and offset
if ($limit !== null) {
@@ -47,20 +47,21 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
protected function processOrderByForList(QueryBuilder $qb, $orderBy)
{
$fieldName = is_array($orderBy) ? key($orderBy) : $orderBy;
$order = is_array($orderBy) ? $orderBy[$fieldName] : 'ASC';
$fieldName = \is_array($orderBy) ? \key($orderBy) : $orderBy;
$order = \is_array($orderBy) ? $orderBy[$fieldName] : 'ASC';
if (in_array($fieldName, ['visits', 'visitsCount', 'visitCount'], true)) {
$qb->addSelect('COUNT(v) AS totalVisits')
if (\in_array($fieldName, ['visits', 'visitsCount', 'visitCount'], true)) {
$qb->addSelect('COUNT(DISTINCT v) AS totalVisits')
->leftJoin('s.visits', 'v')
->groupBy('s')
->orderBy('totalVisits', $order);
return array_column($qb->getQuery()->getResult(), 0);
} elseif (in_array($fieldName, ['originalUrl', 'shortCode', 'dateCreated'], true)) {
$qb->orderBy('s.' . $fieldName, $order);
return \array_column($qb->getQuery()->getResult(), 0);
}
if (\in_array($fieldName, ['originalUrl', 'shortCode', 'dateCreated'], true)) {
$qb->orderBy('s.' . $fieldName, $order);
}
return $qb->getQuery()->getResult();
}
@@ -74,7 +75,7 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
public function countList(string $searchTerm = null, array $tags = []): int
{
$qb = $this->createListQueryBuilder($searchTerm, $tags);
$qb->select('COUNT(s)');
$qb->select('COUNT(DISTINCT s)');
return (int) $qb->getQuery()->getSingleScalarResult();
}
@@ -92,7 +93,10 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
// Apply search term to every searchable field if not empty
if (! empty($searchTerm)) {
$qb->join('s.tags', 't');
// Left join with tags only if no tags were provided. In case of tags, an inner join will be done later
if (empty($tags)) {
$qb->leftJoin('s.tags', 't');
}
$conditions = [
$qb->expr()->like('s.originalUrl', ':searchPattern'),
@@ -102,8 +106,7 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
// Unpack and apply search conditions
$qb->andWhere($qb->expr()->orX(...$conditions));
$searchTerm = '%' . $searchTerm . '%';
$qb->setParameter('searchPattern', $searchTerm);
$qb->setParameter('searchPattern', '%' . $searchTerm . '%');
}
// Filter by tags if provided
@@ -119,7 +122,7 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
* @param string $shortCode
* @return ShortUrl|null
*/
public function findOneByShortCode(string $shortCode)
public function findOneByShortCode(string $shortCode): ?ShortUrl
{
$now = new \DateTimeImmutable();

View File

@@ -6,13 +6,13 @@ namespace Shlinkio\Shlink\Core\Response;
use Fig\Http\Message\StatusCodeInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface as DelegateInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Zend\Diactoros\Response;
use Zend\Expressive\Template\TemplateRendererInterface;
class NotFoundDelegate implements DelegateInterface
class NotFoundHandler implements RequestHandlerInterface
{
const NOT_FOUND_TEMPLATE = 'notFoundTemplate';
public const NOT_FOUND_TEMPLATE = 'notFoundTemplate';
/**
* @var TemplateRendererInterface

View File

@@ -28,16 +28,18 @@ class TagService implements TagServiceInterface
* @return Tag[]
* @throws \UnexpectedValueException
*/
public function listTags()
public function listTags(): array
{
return $this->em->getRepository(Tag::class)->findBy([], ['name' => 'ASC']);
/** @var Tag[] $tags */
$tags = $this->em->getRepository(Tag::class)->findBy([], ['name' => 'ASC']);
return $tags;
}
/**
* @param array $tagNames
* @return void
*/
public function deleteTags(array $tagNames)
public function deleteTags(array $tagNames): void
{
/** @var TagRepository $repo */
$repo = $this->em->getRepository(Tag::class);
@@ -50,7 +52,7 @@ class TagService implements TagServiceInterface
* @param string[] $tagNames
* @return Collection|Tag[]
*/
public function createTags(array $tagNames)
public function createTags(array $tagNames): Collection
{
$tags = $this->tagNamesToEntities($this->em, $tagNames);
$this->em->flush();
@@ -65,7 +67,7 @@ class TagService implements TagServiceInterface
* @throws EntityDoesNotExistException
* @throws ORM\OptimisticLockException
*/
public function renameTag($oldName, $newName)
public function renameTag($oldName, $newName): Tag
{
$criteria = ['name' => $oldName];
/** @var Tag|null $tag */

View File

@@ -12,13 +12,13 @@ interface TagServiceInterface
/**
* @return Tag[]
*/
public function listTags();
public function listTags(): array;
/**
* @param string[] $tagNames
* @return void
*/
public function deleteTags(array $tagNames);
public function deleteTags(array $tagNames): void;
/**
* Provided a list of tag names, creates all that do not exist yet
@@ -26,7 +26,7 @@ interface TagServiceInterface
* @param string[] $tagNames
* @return Collection|Tag[]
*/
public function createTags(array $tagNames);
public function createTags(array $tagNames): Collection;
/**
* @param string $oldName
@@ -34,5 +34,5 @@ interface TagServiceInterface
* @return Tag
* @throws EntityDoesNotExistException
*/
public function renameTag($oldName, $newName);
public function renameTag($oldName, $newName): Tag;
}

View File

@@ -5,15 +5,17 @@ namespace ShlinkioTest\Shlink\Core\Repository;
use Doctrine\Common\Collections\ArrayCollection;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
use ShlinkioTest\Shlink\Common\DbUnit\DatabaseTestCase;
class ShortUrlRepositoryTest extends DatabaseTestCase
{
const ENTITIES_TO_EMPTY = [
protected const ENTITIES_TO_EMPTY = [
ShortUrl::class,
Visit::class,
Tag::class,
];
/**
@@ -38,7 +40,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
$bar = new ShortUrl();
$bar->setOriginalUrl('bar')
->setShortCode('bar')
->setShortCode('bar_very_long_text')
->setValidSince((new \DateTime())->add(new \DateInterval('P1M')));
$this->getEntityManager()->persist($bar);
@@ -79,4 +81,35 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
$this->assertEquals($count, $this->repo->countList());
}
/**
* @test
*/
public function findListProperlyFiltersByTagAndSearchTerm()
{
$tag = new Tag('bar');
$this->getEntityManager()->persist($tag);
$foo = new ShortUrl();
$foo->setOriginalUrl('foo')
->setShortCode('foo')
->setTags(new ArrayCollection([$tag]));
$this->getEntityManager()->persist($foo);
$bar = new ShortUrl();
$bar->setOriginalUrl('bar')
->setShortCode('bar_very_long_text');
$this->getEntityManager()->persist($bar);
$foo2 = new ShortUrl();
$foo2->setOriginalUrl('foo_2')
->setShortCode('foo_2');
$this->getEntityManager()->persist($foo2);
$this->getEntityManager()->flush();
$result = $this->repo->findList(null, null, 'foo', ['bar']);
$this->assertCount(1, $result);
$this->assertSame($foo, $result[0]);
}
}

View File

@@ -9,7 +9,7 @@ use ShlinkioTest\Shlink\Common\DbUnit\DatabaseTestCase;
class TagRepositoryTest extends DatabaseTestCase
{
const ENTITIES_TO_EMPTY = [
protected const ENTITIES_TO_EMPTY = [
Tag::class,
];

View File

@@ -12,7 +12,7 @@ use ShlinkioTest\Shlink\Common\DbUnit\DatabaseTestCase;
class VisitRepositoryTest extends DatabaseTestCase
{
const ENTITIES_TO_EMPTY = [
protected const ENTITIES_TO_EMPTY = [
VisitLocation::class,
Visit::class,
ShortUrl::class,

View File

@@ -7,15 +7,15 @@ use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\MethodProphecy;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Response\NotFoundDelegate;
use Shlinkio\Shlink\Core\Response\NotFoundHandler;
use Zend\Diactoros\Response;
use Zend\Diactoros\ServerRequestFactory;
use Zend\Expressive\Template\TemplateRendererInterface;
class NotFoundDelegateTest extends TestCase
class NotFoundHandlerTest extends TestCase
{
/**
* @var NotFoundDelegate
* @var NotFoundHandler
*/
private $delegate;
/**
@@ -26,7 +26,7 @@ class NotFoundDelegateTest extends TestCase
public function setUp()
{
$this->renderer = $this->prophesize(TemplateRendererInterface::class);
$this->delegate = new NotFoundDelegate($this->renderer->reveal());
$this->delegate = new NotFoundHandler($this->renderer->reveal());
}
/**

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Rest;
return [
'auth' => [
'routes_whitelist' => [
Action\AuthenticateAction::class,
Action\ShortCode\SingleStepCreateShortCodeAction::class,
],
],
];

View File

@@ -20,12 +20,13 @@ return [
ApiKeyService::class => ConfigAbstractFactory::class,
Action\AuthenticateAction::class => ConfigAbstractFactory::class,
Action\CreateShortcodeAction::class => ConfigAbstractFactory::class,
Action\EditShortCodeAction::class => ConfigAbstractFactory::class,
Action\ResolveUrlAction::class => ConfigAbstractFactory::class,
Action\GetVisitsAction::class => ConfigAbstractFactory::class,
Action\ListShortcodesAction::class => ConfigAbstractFactory::class,
Action\EditShortcodeTagsAction::class => ConfigAbstractFactory::class,
Action\ShortCode\CreateShortCodeAction::class => ConfigAbstractFactory::class,
Action\ShortCode\SingleStepCreateShortCodeAction::class => ConfigAbstractFactory::class,
Action\ShortCode\EditShortCodeAction::class => ConfigAbstractFactory::class,
Action\ShortCode\ResolveUrlAction::class => ConfigAbstractFactory::class,
Action\Visit\GetVisitsAction::class => ConfigAbstractFactory::class,
Action\ShortCode\ListShortCodesAction::class => ConfigAbstractFactory::class,
Action\ShortCode\EditShortCodeTagsAction::class => ConfigAbstractFactory::class,
Action\Tag\ListTagsAction::class => ConfigAbstractFactory::class,
Action\Tag\DeleteTagsAction::class => ConfigAbstractFactory::class,
Action\Tag\CreateTagsAction::class => ConfigAbstractFactory::class,
@@ -35,6 +36,7 @@ return [
Middleware\CrossDomainMiddleware::class => InvokableFactory::class,
Middleware\PathVersionMiddleware::class => InvokableFactory::class,
Middleware\CheckAuthenticationMiddleware::class => ConfigAbstractFactory::class,
Middleware\ShortCode\CreateShortCodeContentNegotiationMiddleware::class => InvokableFactory::class,
],
],
@@ -43,23 +45,39 @@ return [
ApiKeyService::class => ['em'],
Action\AuthenticateAction::class => [ApiKeyService::class, JWTService::class, 'translator', 'Logger_Shlink'],
Action\CreateShortcodeAction::class => [
Action\ShortCode\CreateShortCodeAction::class => [
Service\UrlShortener::class,
'translator',
'config.url_shortener.domain',
'Logger_Shlink',
],
Action\EditShortCodeAction::class => [Service\ShortUrlService::class, 'translator', 'Logger_Shlink',],
Action\ResolveUrlAction::class => [Service\UrlShortener::class, 'translator'],
Action\GetVisitsAction::class => [Service\VisitsTracker::class, 'translator', 'Logger_Shlink'],
Action\ListShortcodesAction::class => [Service\ShortUrlService::class, 'translator', 'Logger_Shlink'],
Action\EditShortcodeTagsAction::class => [Service\ShortUrlService::class, 'translator', 'Logger_Shlink'],
Action\ShortCode\SingleStepCreateShortCodeAction::class => [
Service\UrlShortener::class,
'translator',
ApiKeyService::class,
'config.url_shortener.domain',
'Logger_Shlink',
],
Action\ShortCode\EditShortCodeAction::class => [Service\ShortUrlService::class, 'translator', 'Logger_Shlink',],
Action\ShortCode\ResolveUrlAction::class => [Service\UrlShortener::class, 'translator'],
Action\Visit\GetVisitsAction::class => [Service\VisitsTracker::class, 'translator', 'Logger_Shlink'],
Action\ShortCode\ListShortCodesAction::class => [Service\ShortUrlService::class, 'translator', 'Logger_Shlink'],
Action\ShortCode\EditShortCodeTagsAction::class => [
Service\ShortUrlService::class,
'translator',
'Logger_Shlink',
],
Action\Tag\ListTagsAction::class => [Service\Tag\TagService::class, LoggerInterface::class],
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, Translator::class, LoggerInterface::class],
Middleware\CheckAuthenticationMiddleware::class => [JWTService::class, 'translator', 'Logger_Shlink'],
Middleware\CheckAuthenticationMiddleware::class => [
JWTService::class,
'translator',
'config.auth.routes_whitelist',
'Logger_Shlink',
],
],
];

View File

@@ -1,84 +1,35 @@
<?php
declare(strict_types=1);
use Fig\Http\Message\RequestMethodInterface as RequestMethod;
namespace Shlinkio\Shlink\Rest;
use Shlinkio\Shlink\Rest\Action;
return [
'routes' => [
[
'name' => Action\AuthenticateAction::class,
'path' => '/authenticate',
'middleware' => Action\AuthenticateAction::class,
'allowed_methods' => [RequestMethod::METHOD_POST],
],
Action\AuthenticateAction::getRouteDef(),
// Short codes
[
'name' => Action\CreateShortcodeAction::class,
'path' => '/short-codes',
'middleware' => Action\CreateShortcodeAction::class,
'allowed_methods' => [RequestMethod::METHOD_POST],
],
[
'name' => Action\EditShortCodeAction::class,
'path' => '/short-codes/{shortCode}',
'middleware' => Action\EditShortCodeAction::class,
'allowed_methods' => [RequestMethod::METHOD_PUT],
],
[
'name' => Action\ResolveUrlAction::class,
'path' => '/short-codes/{shortCode}',
'middleware' => Action\ResolveUrlAction::class,
'allowed_methods' => [RequestMethod::METHOD_GET],
],
[
'name' => Action\ListShortcodesAction::class,
'path' => '/short-codes',
'middleware' => Action\ListShortcodesAction::class,
'allowed_methods' => [RequestMethod::METHOD_GET],
],
[
'name' => Action\EditShortcodeTagsAction::class,
'path' => '/short-codes/{shortCode}/tags',
'middleware' => Action\EditShortcodeTagsAction::class,
'allowed_methods' => [RequestMethod::METHOD_PUT],
],
Action\ShortCode\CreateShortCodeAction::getRouteDef([
Middleware\ShortCode\CreateShortCodeContentNegotiationMiddleware::class,
]),
Action\ShortCode\SingleStepCreateShortCodeAction::getRouteDef([
Middleware\ShortCode\CreateShortCodeContentNegotiationMiddleware::class,
]),
Action\ShortCode\EditShortCodeAction::getRouteDef(),
Action\ShortCode\ResolveUrlAction::getRouteDef(),
Action\ShortCode\ListShortCodesAction::getRouteDef(),
Action\ShortCode\EditShortCodeTagsAction::getRouteDef(),
// Visits
[
'name' => Action\GetVisitsAction::class,
'path' => '/short-codes/{shortCode}/visits',
'middleware' => Action\GetVisitsAction::class,
'allowed_methods' => [RequestMethod::METHOD_GET],
],
Action\Visit\GetVisitsAction::getRouteDef(),
// Tags
[
'name' => Action\Tag\ListTagsAction::class,
'path' => '/tags',
'middleware' => Action\Tag\ListTagsAction::class,
'allowed_methods' => [RequestMethod::METHOD_GET],
],
[
'name' => Action\Tag\DeleteTagsAction::class,
'path' => '/tags',
'middleware' => Action\Tag\DeleteTagsAction::class,
'allowed_methods' => [RequestMethod::METHOD_DELETE],
],
[
'name' => Action\Tag\CreateTagsAction::class,
'path' => '/tags',
'middleware' => Action\Tag\CreateTagsAction::class,
'allowed_methods' => [RequestMethod::METHOD_POST],
],
[
'name' => Action\Tag\UpdateTagAction::class,
'path' => '/tags',
'middleware' => Action\Tag\UpdateTagAction::class,
'allowed_methods' => [RequestMethod::METHOD_PUT],
],
Action\Tag\ListTagsAction::getRouteDef(),
Action\Tag\DeleteTagsAction::getRouteDef(),
Action\Tag\CreateTagsAction::getRouteDef(),
Action\Tag\UpdateTagAction::getRouteDef(),
],
];

Binary file not shown.

View File

@@ -1,15 +1,15 @@
msgid ""
msgstr ""
"Project-Id-Version: Shlink 1.0\n"
"POT-Creation-Date: 2018-01-21 09:40+0100\n"
"PO-Revision-Date: 2018-01-21 09:40+0100\n"
"POT-Creation-Date: 2018-05-06 12:34+0200\n"
"PO-Revision-Date: 2018-05-06 12:35+0200\n"
"Last-Translator: Alejandro Celaya <alejandro@alejandrocelaya.com>\n"
"Language-Team: \n"
"Language: es_ES\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: Poedit 2.0.4\n"
"X-Generator: Poedit 2.0.6\n"
"X-Poedit-Basepath: ..\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"X-Poedit-SourceCharset: UTF-8\n"
@@ -25,9 +25,6 @@ msgstr ""
msgid "Provided API key does not exist or is invalid."
msgstr "La clave de API proporcionada no existe o es inválida."
msgid "A URL was not provided"
msgstr "No se ha proporcionado una URL"
#, php-format
msgid "Provided URL %s is invalid. Try with a different one."
msgstr "La URL proporcionada \"%s\" es inválida. Prueba con una diferente."
@@ -39,6 +36,9 @@ msgstr "El slug proporcionado \"%s\" ya está en uso. Prueba con uno diferente."
msgid "Unexpected error occurred"
msgstr "Ocurrió un error inesperado"
msgid "A URL was not provided"
msgstr "No se ha proporcionado una URL"
#, php-format
msgid "No URL found for short code \"%s\""
msgstr "No se ha encontrado ninguna URL para el código corto \"%s\""
@@ -49,14 +49,13 @@ msgstr "Los datos proporcionados son inválidos."
msgid "A list of tags was not provided"
msgstr "No se ha proporcionado una lista de etiquetas"
#, php-format
msgid "Provided short code %s does not exist"
msgstr "El código corto \"%s\" proporcionado no existe"
#, php-format
msgid "Provided short code \"%s\" has an invalid format"
msgstr "El código corto proporcionado \"%s\" tiene un formato no inválido"
msgid "No API key was provided or it is not valid"
msgstr "No se ha proporcionado una clave de API o esta es inválida"
msgid ""
"You have to provide both 'oldName' and 'newName' params in order to properly "
"rename the tag"
@@ -68,6 +67,10 @@ msgstr ""
msgid "It wasn't possible to find a tag with name '%s'"
msgstr "No fue posible encontrar una etiqueta con el nombre '%s'"
#, php-format
msgid "Provided short code %s does not exist"
msgstr "El código corto \"%s\" proporcionado no existe"
#, php-format
msgid "You need to provide the Bearer type in the %s header."
msgstr "Debes proporcionar el typo Bearer en la cabecera %s."

View File

@@ -11,6 +11,9 @@ use Psr\Log\NullLogger;
abstract class AbstractRestAction implements RequestHandlerInterface, RequestMethodInterface, StatusCodeInterface
{
protected const ROUTE_PATH = '';
protected const ROUTE_ALLOWED_METHODS = [];
/**
* @var LoggerInterface
*/
@@ -20,4 +23,14 @@ abstract class AbstractRestAction implements RequestHandlerInterface, RequestMet
{
$this->logger = $logger ?: new NullLogger();
}
public static function getRouteDef(array $prevMiddleware = [], array $postMiddleware = []): array
{
return [
'name' => static::class,
'middleware' => \array_merge($prevMiddleware, [static::class], $postMiddleware),
'path' => static::ROUTE_PATH,
'allowed_methods' => static::ROUTE_ALLOWED_METHODS,
];
}
}

View File

@@ -15,6 +15,9 @@ use Zend\I18n\Translator\TranslatorInterface;
class AuthenticateAction extends AbstractRestAction
{
protected const ROUTE_PATH = '/authenticate';
protected const ROUTE_ALLOWED_METHODS = [self::METHOD_POST];
/**
* @var TranslatorInterface
*/

View File

@@ -1,20 +1,23 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Rest\Action;
namespace Shlinkio\Shlink\Rest\Action\ShortCode;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Exception\InvalidArgumentException;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\Model\CreateShortCodeData;
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
use Shlinkio\Shlink\Rest\Util\RestUtils;
use Zend\Diactoros\Response\JsonResponse;
use Zend\Diactoros\Uri;
use Zend\I18n\Translator\TranslatorInterface;
class CreateShortcodeAction extends AbstractRestAction
abstract class AbstractCreateShortCodeAction extends AbstractRestAction
{
/**
* @var UrlShortenerInterface
@@ -27,7 +30,7 @@ class CreateShortcodeAction extends AbstractRestAction
/**
* @var TranslatorInterface
*/
private $translator;
protected $translator;
public function __construct(
UrlShortenerInterface $urlShortener,
@@ -48,31 +51,34 @@ class CreateShortcodeAction extends AbstractRestAction
*/
public function handle(Request $request): Response
{
$postData = (array) $request->getParsedBody();
if (! isset($postData['longUrl'])) {
try {
$shortCodeData = $this->buildUrlToShortCodeData($request);
$shortCodeMeta = $shortCodeData->getMeta();
$longUrl = $shortCodeData->getLongUrl();
$customSlug = $shortCodeMeta->getCustomSlug();
} catch (InvalidArgumentException $e) {
$this->logger->warning('Provided data is invalid.' . PHP_EOL . $e);
return new JsonResponse([
'error' => RestUtils::INVALID_ARGUMENT_ERROR,
'message' => $this->translator->translate('A URL was not provided'),
'message' => $e->getMessage(),
], self::STATUS_BAD_REQUEST);
}
$longUrl = $postData['longUrl'];
$customSlug = $postData['customSlug'] ?? null;
try {
$shortCode = $this->urlShortener->urlToShortCode(
new Uri($longUrl),
(array) ($postData['tags'] ?? []),
$this->getOptionalDate($postData, 'validSince'),
$this->getOptionalDate($postData, 'validUntil'),
$longUrl,
$shortCodeData->getTags(),
$shortCodeMeta->getValidSince(),
$shortCodeMeta->getValidUntil(),
$customSlug,
isset($postData['maxVisits']) ? (int) $postData['maxVisits'] : null
$shortCodeMeta->getMaxVisits()
);
$shortUrl = (new Uri())->withPath($shortCode)
->withScheme($this->domainConfig['schema'])
->withHost($this->domainConfig['hostname']);
return new JsonResponse([
'longUrl' => $longUrl,
'longUrl' => (string) $longUrl,
'shortUrl' => (string) $shortUrl,
'shortCode' => $shortCode,
]);
@@ -80,7 +86,7 @@ class CreateShortcodeAction extends AbstractRestAction
$this->logger->warning('Provided Invalid URL.' . PHP_EOL . $e);
return new JsonResponse([
'error' => RestUtils::getRestErrorCodeFromException($e),
'message' => sprintf(
'message' => \sprintf(
$this->translator->translate('Provided URL %s is invalid. Try with a different one.'),
$longUrl
),
@@ -89,7 +95,7 @@ class CreateShortcodeAction extends AbstractRestAction
$this->logger->warning('Provided non-unique slug.' . PHP_EOL . $e);
return new JsonResponse([
'error' => RestUtils::getRestErrorCodeFromException($e),
'message' => sprintf(
'message' => \sprintf(
$this->translator->translate('Provided slug %s is already in use. Try with a different one.'),
$customSlug
),
@@ -103,8 +109,10 @@ class CreateShortcodeAction extends AbstractRestAction
}
}
private function getOptionalDate(array $postData, string $fieldName)
{
return isset($postData[$fieldName]) ? new \DateTime($postData[$fieldName]) : null;
}
/**
* @param Request $request
* @return CreateShortCodeData
* @throws InvalidArgumentException
*/
abstract protected function buildUrlToShortCodeData(Request $request): CreateShortCodeData;
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Rest\Action\ShortCode;
use Psr\Http\Message\ServerRequestInterface as Request;
use Shlinkio\Shlink\Core\Exception\InvalidArgumentException;
use Shlinkio\Shlink\Core\Model\CreateShortCodeData;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Zend\Diactoros\Uri;
class CreateShortCodeAction extends AbstractCreateShortCodeAction
{
protected const ROUTE_PATH = '/short-codes';
protected const ROUTE_ALLOWED_METHODS = [self::METHOD_POST];
/**
* @param Request $request
* @return CreateShortCodeData
* @throws InvalidArgumentException
* @throws \InvalidArgumentException
*/
protected function buildUrlToShortCodeData(Request $request): CreateShortCodeData
{
$postData = (array) $request->getParsedBody();
if (! isset($postData['longUrl'])) {
throw new InvalidArgumentException($this->translator->translate('A URL was not provided'));
}
return new CreateShortCodeData(
new Uri($postData['longUrl']),
(array) ($postData['tags'] ?? []),
ShortUrlMeta::createFromParams(
$this->getOptionalDate($postData, 'validSince'),
$this->getOptionalDate($postData, 'validUntil'),
$postData['customSlug'] ?? null,
isset($postData['maxVisits']) ? (int) $postData['maxVisits'] : null
)
);
}
private function getOptionalDate(array $postData, string $fieldName)
{
return isset($postData[$fieldName]) ? new \DateTime($postData[$fieldName]) : null;
}
}

View File

@@ -1,7 +1,7 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Rest\Action;
namespace Shlinkio\Shlink\Rest\Action\ShortCode;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
@@ -9,6 +9,7 @@ use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Exception;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
use Shlinkio\Shlink\Rest\Util\RestUtils;
use Zend\Diactoros\Response\EmptyResponse;
use Zend\Diactoros\Response\JsonResponse;
@@ -16,6 +17,9 @@ use Zend\I18n\Translator\TranslatorInterface;
class EditShortCodeAction extends AbstractRestAction
{
protected const ROUTE_PATH = '/short-codes/{shortCode}';
protected const ROUTE_ALLOWED_METHODS = [self::METHOD_PUT];
/**
* @var ShortUrlServiceInterface
*/

View File

@@ -1,19 +1,23 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Rest\Action;
namespace Shlinkio\Shlink\Rest\Action\ShortCode;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
use Shlinkio\Shlink\Rest\Util\RestUtils;
use Zend\Diactoros\Response\JsonResponse;
use Zend\I18n\Translator\TranslatorInterface;
class EditShortcodeTagsAction extends AbstractRestAction
class EditShortCodeTagsAction extends AbstractRestAction
{
protected const ROUTE_PATH = '/short-codes/{shortCode}/tags';
protected const ROUTE_ALLOWED_METHODS = [self::METHOD_PUT];
/**
* @var ShortUrlServiceInterface
*/

View File

@@ -1,21 +1,25 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Rest\Action;
namespace Shlinkio\Shlink\Rest\Action\ShortCode;
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\Service\ShortUrlServiceInterface;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
use Shlinkio\Shlink\Rest\Util\RestUtils;
use Zend\Diactoros\Response\JsonResponse;
use Zend\I18n\Translator\TranslatorInterface;
class ListShortcodesAction extends AbstractRestAction
class ListShortCodesAction extends AbstractRestAction
{
use PaginatorUtilsTrait;
protected const ROUTE_PATH = '/short-codes';
protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET];
/**
* @var ShortUrlServiceInterface
*/

View File

@@ -1,7 +1,7 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Rest\Action;
namespace Shlinkio\Shlink\Rest\Action\ShortCode;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
@@ -9,12 +9,16 @@ use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
use Shlinkio\Shlink\Rest\Util\RestUtils;
use Zend\Diactoros\Response\JsonResponse;
use Zend\I18n\Translator\TranslatorInterface;
class ResolveUrlAction extends AbstractRestAction
{
protected const ROUTE_PATH = '/short-codes/{shortCode}';
protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET];
/**
* @var UrlShortenerInterface
*/

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Rest\Action\ShortCode;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Exception\InvalidArgumentException;
use Shlinkio\Shlink\Core\Model\CreateShortCodeData;
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use Zend\Diactoros\Uri;
use Zend\I18n\Translator\TranslatorInterface;
class SingleStepCreateShortCodeAction extends AbstractCreateShortCodeAction
{
protected const ROUTE_PATH = '/short-codes/shorten';
protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET];
/**
* @var ApiKeyServiceInterface
*/
private $apiKeyService;
public function __construct(
UrlShortenerInterface $urlShortener,
TranslatorInterface $translator,
ApiKeyServiceInterface $apiKeyService,
array $domainConfig,
LoggerInterface $logger = null
) {
parent::__construct($urlShortener, $translator, $domainConfig, $logger);
$this->apiKeyService = $apiKeyService;
}
/**
* @param Request $request
* @return CreateShortCodeData
* @throws \InvalidArgumentException
* @throws InvalidArgumentException
*/
protected function buildUrlToShortCodeData(Request $request): CreateShortCodeData
{
$query = $request->getQueryParams();
// Check provided API key
$apiKey = $this->apiKeyService->getByKey($query['apiKey'] ?? '');
if ($apiKey === null || ! $apiKey->isValid()) {
throw new InvalidArgumentException(
$this->translator->translate('No API key was provided or it is not valid')
);
}
if (! isset($query['longUrl'])) {
throw new InvalidArgumentException($this->translator->translate('A URL was not provided'));
}
return new CreateShortCodeData(new Uri($query['longUrl']));
}
}

View File

@@ -12,6 +12,9 @@ use Zend\Diactoros\Response\JsonResponse;
class CreateTagsAction extends AbstractRestAction
{
protected const ROUTE_PATH = '/tags';
protected const ROUTE_ALLOWED_METHODS = [self::METHOD_POST];
/**
* @var TagServiceInterface
*/

View File

@@ -12,6 +12,9 @@ use Zend\Diactoros\Response\EmptyResponse;
class DeleteTagsAction extends AbstractRestAction
{
protected const ROUTE_PATH = '/tags';
protected const ROUTE_ALLOWED_METHODS = [self::METHOD_DELETE];
/**
* @var TagServiceInterface
*/

View File

@@ -12,6 +12,9 @@ use Zend\Diactoros\Response\JsonResponse;
class ListTagsAction extends AbstractRestAction
{
protected const ROUTE_PATH = '/tags';
protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET];
/**
* @var TagServiceInterface
*/

View File

@@ -16,6 +16,9 @@ use Zend\I18n\Translator\TranslatorInterface;
class UpdateTagAction extends AbstractRestAction
{
protected const ROUTE_PATH = '/tags';
protected const ROUTE_ALLOWED_METHODS = [self::METHOD_PUT];
/**
* @var TagServiceInterface
*/

View File

@@ -1,7 +1,7 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Rest\Action;
namespace Shlinkio\Shlink\Rest\Action\Visit;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
@@ -9,12 +9,16 @@ use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
use Shlinkio\Shlink\Rest\Util\RestUtils;
use Zend\Diactoros\Response\JsonResponse;
use Zend\I18n\Translator\TranslatorInterface;
class GetVisitsAction extends AbstractRestAction
{
protected const ROUTE_PATH = '/short-codes/{shortCode}/visits';
protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET];
/**
* @var VisitsTrackerInterface
*/

View File

@@ -10,7 +10,6 @@ use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Shlinkio\Shlink\Rest\Action\AuthenticateAction;
use Shlinkio\Shlink\Rest\Authentication\JWTServiceInterface;
use Shlinkio\Shlink\Rest\Exception\AuthenticationException;
use Shlinkio\Shlink\Rest\Util\RestUtils;
@@ -35,14 +34,20 @@ class CheckAuthenticationMiddleware implements MiddlewareInterface, StatusCodeIn
* @var LoggerInterface
*/
private $logger;
/**
* @var array
*/
private $routesWhitelist;
public function __construct(
JWTServiceInterface $jwtService,
TranslatorInterface $translator,
array $routesWhitelist,
LoggerInterface $logger = null
) {
$this->translator = $translator;
$this->jwtService = $jwtService;
$this->routesWhitelist = $routesWhitelist;
$this->logger = $logger ?: new NullLogger();
}
@@ -64,8 +69,8 @@ class CheckAuthenticationMiddleware implements MiddlewareInterface, StatusCodeIn
$routeResult = $request->getAttribute(RouteResult::class);
if ($routeResult === null
|| $routeResult->isFailure()
|| $routeResult->getMatchedRouteName() === AuthenticateAction::class
|| $request->getMethod() === 'OPTIONS'
|| \in_array($routeResult->getMatchedRouteName(), $this->routesWhitelist, true)
) {
return $handler->handle($request);
}

View File

@@ -18,27 +18,16 @@ class PathVersionMiddleware implements MiddlewareInterface
* @param RequestHandlerInterface $handler
*
* @return Response
* @throws \InvalidArgumentException
*/
public function process(Request $request, RequestHandlerInterface $handler): Response
{
$uri = $request->getUri();
$path = $uri->getPath();
// TODO Workaround... Do not process the request if it does not start with rest
if (\strpos($path, '/rest') !== 0) {
return $handler->handle($request);
}
// If the path does not begin with the version number, prepend v1 by default for BC compatibility purposes
if (\strpos($path, '/rest/v') !== 0) {
$parts = \explode('/', $path);
// Remove the first empty part and the rest part
\array_shift($parts);
\array_shift($parts);
// Prepend the version prefix
\array_unshift($parts, '/rest/v1');
$request = $request->withUri($uri->withPath(\implode('/', $parts)));
if (\strpos($path, '/v') !== 0) {
$request = $request->withUri($uri->withPath('/v1' . $uri->getPath()));
}
return $handler->handle($request);

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Rest\Middleware\ShortCode;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Zend\Diactoros\Response;
use Zend\Diactoros\Response\JsonResponse;
class CreateShortCodeContentNegotiationMiddleware implements MiddlewareInterface
{
private const PLAIN_TEXT = 'text';
private const JSON = 'json';
/**
* Process an incoming server request and return a response, optionally delegating
* response creation to a handler.
* @throws \RuntimeException
* @throws \InvalidArgumentException
*/
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$response = $handler->handle($request);
// If the response is not JSON, return it as is
if (! $response instanceof JsonResponse) {
return $response;
}
$query = $request->getQueryParams();
$acceptedType = isset($query['format'])
? $this->determineAcceptTypeFromQuery($query)
: $this->determineAcceptTypeFromHeader($request->getHeaderLine('Accept'));
// If JSON was requested, return the response from next handler as is
if ($acceptedType === self::JSON) {
return $response;
}
// If requested, return a plain text response containing the short URL only
$resp = (new Response())->withHeader('Content-Type', 'text/plain');
$body = $resp->getBody();
$body->write($this->determineBody($response));
$body->rewind();
return $resp;
}
private function determineAcceptTypeFromQuery(array $query): string
{
if (! isset($query['format'])) {
return self::JSON;
}
$format = \strtolower((string) $query['format']);
return $format === 'txt' ? self::PLAIN_TEXT : self::JSON;
}
private function determineAcceptTypeFromHeader(string $acceptValue): string
{
$accepts = \explode(',', $acceptValue);
$accept = \strtolower(\array_shift($accepts));
return \strpos($accept, 'text/plain') !== false ? self::PLAIN_TEXT : self::JSON;
}
private function determineBody(JsonResponse $resp): string
{
$payload = $resp->getPayload();
return $payload['shortUrl'] ?? $payload['error'] ?? '';
}
}

View File

@@ -25,10 +25,10 @@ class ApiKeyService implements ApiKeyServiceInterface
* @param \DateTime $expirationDate
* @return ApiKey
*/
public function create(\DateTime $expirationDate = null)
public function create(\DateTime $expirationDate = null): ApiKey
{
$key = new ApiKey();
if (isset($expirationDate)) {
if ($expirationDate !== null) {
$key->setExpirationDate($expirationDate);
}
@@ -44,7 +44,7 @@ class ApiKeyService implements ApiKeyServiceInterface
* @param string $key
* @return bool
*/
public function check($key)
public function check(string $key): bool
{
/** @var ApiKey|null $apiKey */
$apiKey = $this->getByKey($key);
@@ -58,7 +58,7 @@ class ApiKeyService implements ApiKeyServiceInterface
* @return ApiKey
* @throws InvalidArgumentException
*/
public function disable($key)
public function disable(string $key): ApiKey
{
/** @var ApiKey|null $apiKey */
$apiKey = $this->getByKey($key);
@@ -77,10 +77,12 @@ class ApiKeyService implements ApiKeyServiceInterface
* @param bool $enabledOnly Tells if only enabled keys should be returned
* @return ApiKey[]
*/
public function listKeys($enabledOnly = false)
public function listKeys(bool $enabledOnly = false): array
{
$conditions = $enabledOnly ? ['enabled' => true] : [];
return $this->em->getRepository(ApiKey::class)->findBy($conditions);
/** @var ApiKey[] $apiKeys */
$apiKeys = $this->em->getRepository(ApiKey::class)->findBy($conditions);
return $apiKeys;
}
/**
@@ -89,7 +91,7 @@ class ApiKeyService implements ApiKeyServiceInterface
* @param string $key
* @return ApiKey|null
*/
public function getByKey($key)
public function getByKey(string $key): ?ApiKey
{
/** @var ApiKey|null $apiKey */
$apiKey = $this->em->getRepository(ApiKey::class)->findOneBy([

View File

@@ -14,7 +14,7 @@ interface ApiKeyServiceInterface
* @param \DateTime $expirationDate
* @return ApiKey
*/
public function create(\DateTime $expirationDate = null);
public function create(\DateTime $expirationDate = null): ApiKey;
/**
* Checks if provided key is a valid api key
@@ -22,7 +22,7 @@ interface ApiKeyServiceInterface
* @param string $key
* @return bool
*/
public function check($key);
public function check(string $key): bool;
/**
* Disables provided api key
@@ -31,7 +31,7 @@ interface ApiKeyServiceInterface
* @return ApiKey
* @throws InvalidArgumentException
*/
public function disable($key);
public function disable(string $key): ApiKey;
/**
* Lists all existing api keys
@@ -39,7 +39,7 @@ interface ApiKeyServiceInterface
* @param bool $enabledOnly Tells if only enabled keys should be returned
* @return ApiKey[]
*/
public function listKeys($enabledOnly = false);
public function listKeys(bool $enabledOnly = false): array;
/**
* Tries to find one API key by its key string
@@ -47,5 +47,5 @@ interface ApiKeyServiceInterface
* @param string $key
* @return ApiKey|null
*/
public function getByKey($key);
public function getByKey(string $key): ?ApiKey;
}

View File

@@ -30,6 +30,7 @@ class RestUtils
case $e instanceof Core\NonUniqueSlugException:
return self::INVALID_SLUG_ERROR;
case $e instanceof Common\InvalidArgumentException:
case $e instanceof Core\InvalidArgumentException:
case $e instanceof Core\ValidationException:
return self::INVALID_ARGUMENT_ERROR;
case $e instanceof Rest\AuthenticationException:

View File

@@ -1,7 +1,7 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Rest\Action;
namespace ShlinkioTest\Shlink\Rest\Action\ShortCode;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
@@ -9,16 +9,16 @@ use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\Service\UrlShortener;
use Shlinkio\Shlink\Rest\Action\CreateShortcodeAction;
use Shlinkio\Shlink\Rest\Action\ShortCode\CreateShortCodeAction;
use Shlinkio\Shlink\Rest\Util\RestUtils;
use Zend\Diactoros\ServerRequestFactory;
use Zend\Diactoros\Uri;
use Zend\I18n\Translator\Translator;
class CreateShortcodeActionTest extends TestCase
class CreateShortCodeActionTest extends TestCase
{
/**
* @var CreateShortcodeAction
* @var CreateShortCodeAction
*/
protected $action;
/**
@@ -29,7 +29,7 @@ class CreateShortcodeActionTest extends TestCase
public function setUp()
{
$this->urlShortener = $this->prophesize(UrlShortener::class);
$this->action = new CreateShortcodeAction($this->urlShortener->reveal(), Translator::factory([]), [
$this->action = new CreateShortCodeAction($this->urlShortener->reveal(), Translator::factory([]), [
'schema' => 'http',
'hostname' => 'foo.com',
]);

View File

@@ -1,7 +1,7 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Rest\Action;
namespace ShlinkioTest\Shlink\Rest\Action\ShortCode;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
@@ -9,7 +9,7 @@ use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
use Shlinkio\Shlink\Rest\Action\EditShortCodeAction;
use Shlinkio\Shlink\Rest\Action\ShortCode\EditShortCodeAction;
use Shlinkio\Shlink\Rest\Util\RestUtils;
use Zend\Diactoros\Response\JsonResponse;
use Zend\Diactoros\ServerRequestFactory;

View File

@@ -1,21 +1,21 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Rest\Action;
namespace ShlinkioTest\Shlink\Rest\Action\ShortCode;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
use Shlinkio\Shlink\Core\Service\ShortUrlService;
use Shlinkio\Shlink\Rest\Action\EditShortcodeTagsAction;
use Shlinkio\Shlink\Rest\Action\ShortCode\EditShortCodeTagsAction;
use Zend\Diactoros\ServerRequestFactory;
use Zend\I18n\Translator\Translator;
class EditShortcodeTagsActionTest extends TestCase
class EditShortCodeTagsActionTest extends TestCase
{
/**
* @var EditShortcodeTagsAction
* @var EditShortCodeTagsAction
*/
protected $action;
/**
@@ -26,7 +26,7 @@ class EditShortcodeTagsActionTest extends TestCase
public function setUp()
{
$this->shortUrlService = $this->prophesize(ShortUrlService::class);
$this->action = new EditShortcodeTagsAction($this->shortUrlService->reveal(), Translator::factory([]));
$this->action = new EditShortCodeTagsAction($this->shortUrlService->reveal(), Translator::factory([]));
}
/**

View File

@@ -1,12 +1,12 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Rest\Action;
namespace ShlinkioTest\Shlink\Rest\Action\ShortCode;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Service\ShortUrlService;
use Shlinkio\Shlink\Rest\Action\ListShortcodesAction;
use Shlinkio\Shlink\Rest\Action\ShortCode\ListShortCodesAction;
use Zend\Diactoros\ServerRequestFactory;
use Zend\I18n\Translator\Translator;
use Zend\Paginator\Adapter\ArrayAdapter;
@@ -15,7 +15,7 @@ use Zend\Paginator\Paginator;
class ListShortCodesActionTest extends TestCase
{
/**
* @var ListShortcodesAction
* @var ListShortCodesAction
*/
protected $action;
/**
@@ -26,7 +26,7 @@ class ListShortCodesActionTest extends TestCase
public function setUp()
{
$this->service = $this->prophesize(ShortUrlService::class);
$this->action = new ListShortcodesAction($this->service->reveal(), Translator::factory([]));
$this->action = new ListShortCodesAction($this->service->reveal(), Translator::factory([]));
}
/**

View File

@@ -1,14 +1,14 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Rest\Action;
namespace ShlinkioTest\Shlink\Rest\Action\ShortCode;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
use Shlinkio\Shlink\Core\Service\UrlShortener;
use Shlinkio\Shlink\Rest\Action\ResolveUrlAction;
use Shlinkio\Shlink\Rest\Action\ShortCode\ResolveUrlAction;
use Shlinkio\Shlink\Rest\Util\RestUtils;
use Zend\Diactoros\ServerRequestFactory;
use Zend\I18n\Translator\Translator;

View File

@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Rest\Action\ShortCode;
use PHPUnit\Framework\Assert;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Psr\Http\Message\UriInterface;
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
use Shlinkio\Shlink\Rest\Action\ShortCode\SingleStepCreateShortCodeAction;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use Zend\Diactoros\Response\JsonResponse;
use Zend\Diactoros\ServerRequestFactory;
use Zend\I18n\Translator\Translator;
class SingleStepCreateShortCodeActionTest extends TestCase
{
/**
* @var SingleStepCreateShortCodeAction
*/
private $action;
/**
* @var ObjectProphecy
*/
private $urlShortener;
/**
* @var ObjectProphecy
*/
private $apiKeyService;
public function setUp()
{
$this->urlShortener = $this->prophesize(UrlShortenerInterface::class);
$this->apiKeyService = $this->prophesize(ApiKeyServiceInterface::class);
$this->action = new SingleStepCreateShortCodeAction(
$this->urlShortener->reveal(),
Translator::factory([]),
$this->apiKeyService->reveal(),
[
'schema' => 'http',
'hostname' => 'foo.com',
]
);
}
/**
* @test
* @dataProvider provideInvalidApiKeys
*/
public function errorResponseIsReturnedIfInvalidApiKeyIsProvided(?ApiKey $apiKey)
{
$request = ServerRequestFactory::fromGlobals()->withQueryParams(['apiKey' => 'abc123']);
$findApiKey = $this->apiKeyService->getByKey('abc123')->willReturn($apiKey);
/** @var JsonResponse $resp */
$resp = $this->action->handle($request);
$payload = $resp->getPayload();
$this->assertEquals(400, $resp->getStatusCode());
$this->assertEquals('INVALID_ARGUMENT', $payload['error']);
$this->assertEquals('No API key was provided or it is not valid', $payload['message']);
$findApiKey->shouldHaveBeenCalled();
}
public function provideInvalidApiKeys(): array
{
return [
[null],
[(new ApiKey())->disable()],
];
}
/**
* @test
*/
public function errorResponseIsReturnedIfNoUrlIsProvided()
{
$request = ServerRequestFactory::fromGlobals()->withQueryParams(['apiKey' => 'abc123']);
$findApiKey = $this->apiKeyService->getByKey('abc123')->willReturn(new ApiKey());
/** @var JsonResponse $resp */
$resp = $this->action->handle($request);
$payload = $resp->getPayload();
$this->assertEquals(400, $resp->getStatusCode());
$this->assertEquals('INVALID_ARGUMENT', $payload['error']);
$this->assertEquals('A URL was not provided', $payload['message']);
$findApiKey->shouldHaveBeenCalled();
}
/**
* @test
*/
public function properDataIsPassedWhenGeneratingShortCode()
{
$request = ServerRequestFactory::fromGlobals()->withQueryParams([
'apiKey' => 'abc123',
'longUrl' => 'http://foobar.com',
]);
$findApiKey = $this->apiKeyService->getByKey('abc123')->willReturn(new ApiKey());
$generateShortCode = $this->urlShortener->urlToShortCode(
Argument::that(function (UriInterface $argument) {
Assert::assertEquals('http://foobar.com', (string) $argument);
return $argument;
}),
[],
null,
null,
null,
null
);
$resp = $this->action->handle($request);
$this->assertEquals(200, $resp->getStatusCode());
$findApiKey->shouldHaveBeenCalled();
$generateShortCode->shouldHaveBeenCalled();
}
}

View File

@@ -1,7 +1,7 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Rest\Action;
namespace ShlinkioTest\Shlink\Rest\Action\Visit;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
@@ -9,7 +9,7 @@ use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Service\VisitsTracker;
use Shlinkio\Shlink\Rest\Action\GetVisitsAction;
use Shlinkio\Shlink\Rest\Action\Visit\GetVisitsAction;
use Zend\Diactoros\ServerRequestFactory;
use Zend\I18n\Translator\Translator;

View File

@@ -37,9 +37,11 @@ class CheckAuthenticationMiddlewareTest extends TestCase
public function setUp()
{
$this->jwtService = $this->prophesize(JWTService::class);
$this->middleware = new CheckAuthenticationMiddleware($this->jwtService->reveal(), Translator::factory([]));
$this->dummyMiddleware = middleware(function ($request, $handler) {
return new Response\EmptyResponse;
$this->middleware = new CheckAuthenticationMiddleware($this->jwtService->reveal(), Translator::factory([]), [
AuthenticateAction::class,
]);
$this->dummyMiddleware = middleware(function () {
return new Response\EmptyResponse();
});
}

View File

@@ -30,22 +30,7 @@ class PathVersionMiddlewareTest extends TestCase
*/
public function whenVersionIsProvidedRequestRemainsUnchanged()
{
$request = ServerRequestFactory::fromGlobals()->withUri(new Uri('/rest/v2/foo'));
$delegate = $this->prophesize(RequestHandlerInterface::class);
$process = $delegate->handle($request)->willReturn(new Response());
$this->middleware->process($request, $delegate->reveal());
$process->shouldHaveBeenCalled();
}
/**
* @test
*/
public function whenPathDoesNotStartWithRestRemainsUnchanged()
{
$request = ServerRequestFactory::fromGlobals()->withUri(new Uri('/foo'));
$request = ServerRequestFactory::fromGlobals()->withUri(new Uri('/v2/foo'));
$delegate = $this->prophesize(RequestHandlerInterface::class);
$process = $delegate->handle($request)->willReturn(new Response());
@@ -60,14 +45,14 @@ class PathVersionMiddlewareTest extends TestCase
*/
public function versionOneIsPrependedWhenNoVersionIsDefined()
{
$request = ServerRequestFactory::fromGlobals()->withUri(new Uri('/rest/bar/baz'));
$request = ServerRequestFactory::fromGlobals()->withUri(new Uri('/bar/baz'));
$delegate = $this->prophesize(RequestHandlerInterface::class);
$delegate->handle(Argument::type(Request::class))->will(function (array $args) use ($request) {
$req = \array_shift($args);
Assert::assertNotSame($request, $req);
Assert::assertEquals('/rest/v1/bar/baz', $req->getUri()->getPath());
Assert::assertEquals('/v1/bar/baz', $req->getUri()->getPath());
return new Response();
});

View File

@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Rest\Middleware\ShortCode;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Shlinkio\Shlink\Rest\Middleware\ShortCode\CreateShortCodeContentNegotiationMiddleware;
use Zend\Diactoros\Response;
use Zend\Diactoros\Response\JsonResponse;
use Zend\Diactoros\ServerRequestFactory;
class CreateShortCodeContentNegotiationMiddlewareTest extends TestCase
{
/**
* @var CreateShortCodeContentNegotiationMiddleware
*/
private $middleware;
/**
* @var RequestHandlerInterface
*/
private $requestHandler;
public function setUp()
{
$this->middleware = new CreateShortCodeContentNegotiationMiddleware();
$this->requestHandler = $this->prophesize(RequestHandlerInterface::class);
}
/**
* @test
*/
public function whenNoJsonResponseIsReturnedNoFurtherOperationsArePerformed()
{
$expectedResp = new Response();
$this->requestHandler->handle(Argument::type(ServerRequestInterface::class))->willReturn($expectedResp);
$resp = $this->middleware->process(ServerRequestFactory::fromGlobals(), $this->requestHandler->reveal());
$this->assertSame($expectedResp, $resp);
}
/**
* @test
* @dataProvider provideData
* @param array $query
*/
public function properResponseIsReturned(?string $accept, array $query, string $expectedContentType)
{
$request = ServerRequestFactory::fromGlobals()->withQueryParams($query);
if ($accept !== null) {
$request = $request->withHeader('Accept', $accept);
}
$handle = $this->requestHandler->handle(Argument::type(ServerRequestInterface::class))->willReturn(
new JsonResponse(['shortUrl' => 'http://doma.in/foo'])
);
$response = $this->middleware->process($request, $this->requestHandler->reveal());
$this->assertEquals($expectedContentType, $response->getHeaderLine('Content-type'));
$handle->shouldHaveBeenCalled();
}
public function provideData(): array
{
return [
[null, [], 'application/json'],
[null, ['format' => 'json'], 'application/json'],
[null, ['format' => 'invalid'], 'application/json'],
[null, ['format' => 'txt'], 'text/plain'],
['application/json', [], 'application/json'],
['application/xml', [], 'application/json'],
['text/plain', [], 'text/plain'],
['application/json', ['format' => 'txt'], 'text/plain'],
];
}
/**
* @test
* @dataProvider provideTextBodies
* @param array $json
*/
public function properBodyIsReturnedInPlainTextResponses(array $json, string $expectedBody)
{
$request = ServerRequestFactory::fromGlobals()->withQueryParams(['format' => 'txt']);
$handle = $this->requestHandler->handle(Argument::type(ServerRequestInterface::class))->willReturn(
new JsonResponse($json)
);
$response = $this->middleware->process($request, $this->requestHandler->reveal());
$this->assertEquals($expectedBody, (string) $response->getBody());
$handle->shouldHaveBeenCalled();
}
public function provideTextBodies(): array
{
return [
[['shortUrl' => 'foobar'], 'foobar'],
[['error' => 'FOO_BAR'], 'FOO_BAR'],
[[], ''],
];
}
}

View File

@@ -2,3 +2,5 @@ parameters:
excludes_analyse:
- module/Common/src/Template/Extension/TranslatorExtension.php
- module/Rest/src/Util/RestUtils.php
ignoreErrors:
- '#is not subtype of Throwable#'