Compare commits

...

43 Commits

Author SHA1 Message Date
Alejandro Celaya
48acded6ed Merge branch 'develop' 2016-10-23 10:30:36 +02:00
Alejandro Celaya
6821f5cf97 Merge branch 'develop' of https://github.com/shlinkio/shlink into develop 2016-10-23 10:30:11 +02:00
Alejandro Celaya
a15b17e08b Fixed regression bug while processing versionning for rest paths 2016-10-23 10:29:54 +02:00
Alejandro Celaya
275b17e1e8 Merge pull request #74 from acelaya/develop
Develop
2016-10-23 00:18:22 +02:00
Alejandro Celaya
27b08ff47b Merge branch 'develop' 2016-10-23 00:17:00 +02:00
Alejandro Celaya
bf7c760ca9 Updated changelog with last versions 2016-10-23 00:13:00 +02:00
Alejandro Celaya
8af9b0ee02 Tagged and summarized all endpoints in swagger docs 2016-10-23 00:07:31 +02:00
Alejandro Celaya
b225c03ef1 Improved swagger definition 2016-10-23 00:02:13 +02:00
Alejandro Celaya
8a12ed6b8c Separated swagger specification into multiple files 2016-10-22 23:44:14 +02:00
Alejandro Celaya
c3fd433446 Merge branch 'feature/67' into develop 2016-10-22 23:15:57 +02:00
Alejandro Celaya
0b9753582d Documented how to order results 2016-10-22 23:13:54 +02:00
Alejandro Celaya
9ac48bfbc5 Added support for ordering in shortcode:list command 2016-10-22 23:10:30 +02:00
Alejandro Celaya
85146e5676 Added support to order short URL lists 2016-10-22 23:02:12 +02:00
Alejandro Celaya
18ae541c93 Improved body parsing on BodyParserMiddleware 2016-10-22 22:17:04 +02:00
Alejandro Celaya
7a665ec26f Merge branch 'feature/72' into develop 2016-10-22 22:11:45 +02:00
Alejandro Celaya
e3cbac38ce Improved output on api-key:list command 2016-10-22 22:11:36 +02:00
Alejandro Celaya
31594d47b3 Created PathVersionMiddlewareTest 2016-10-22 18:52:40 +02:00
Alejandro Celaya
42f86a4a24 Added versioning to API endpoints, allowing not to pass the version which will default to v1 2016-10-22 18:46:53 +02:00
Alejandro Celaya
850ce152cd Merge branch 'feature/58' into develop 2016-10-22 13:16:13 +02:00
Alejandro Celaya
c22bbecdc5 Updated languages 2016-10-22 13:15:35 +02:00
Alejandro Celaya
230f2d155b Documented tags param in GET /short-codes endpoint 2016-10-22 13:13:50 +02:00
Alejandro Celaya
47a2c18c7e Added the ability to filter by tag in shotcodes:list command 2016-10-22 13:11:24 +02:00
Alejandro Celaya
52bb14bd66 Implemented filtering by tags in ListShortcodesAction 2016-10-22 13:04:17 +02:00
Alejandro Celaya
8b9caf02d2 Added tags param to paginable repository adapter 2016-10-22 12:57:24 +02:00
Alejandro Celaya
4580d11d32 Noticed in swagger docs that the searchTerm param is only available from v 1.3.0 of shlink 2016-10-22 12:50:35 +02:00
Alejandro Celaya
8610a158d4 Added searchTerm param to shortcode:list command 2016-10-22 12:48:24 +02:00
Alejandro Celaya
0a6030b35d Documented searchTerm query param for GET /short-codes endpoint 2016-10-22 12:43:22 +02:00
Alejandro Celaya
543c0e62d0 Added search term filtering to short codes list 2016-10-22 12:40:51 +02:00
Alejandro Celaya
4c76e17178 Changed swagger file format from yaml to json 2016-10-22 12:11:31 +02:00
Alejandro Celaya
611a314cdf Merge branch 'develop' 2016-08-29 13:07:25 +02:00
Alejandro Celaya
e7b4d24e5d Merge pull request #68 from acelaya/develop
Develop
2016-08-29 13:07:01 +02:00
Alejandro Celaya
cf60440288 Fixed possible PHP errors being missed while checking REST auth 2016-08-29 12:43:02 +02:00
Alejandro Celaya
15896045f3 Removed logic making visits to be returned for 2 days only if no start or end date were provided 2016-08-28 19:32:07 +02:00
Alejandro Celaya
a9f480ca99 Fixed error while checking an API key that doesn't exist 2016-08-28 09:46:11 +02:00
Alejandro Celaya
4bd67d5f98 Fixed cross domain middleware not exposing the Authorization header 2016-08-27 13:00:41 +02:00
Alejandro Celaya
924ba58f73 Added swagger documentation file 2016-08-26 11:51:51 +02:00
Alejandro Celaya
6eef694315 Merge branch 'develop' 2016-08-21 21:24:31 +02:00
Alejandro Celaya
fe4a4aef34 Updated changelog 2016-08-21 21:24:00 +02:00
Alejandro Celaya
b13c95cf1a Placed cross domain middleware as the first one for rest requests 2016-08-21 21:21:31 +02:00
Alejandro Celaya
c1c325588e Merge branch 'develop' 2016-08-21 19:16:36 +02:00
Alejandro Celaya
faad60f79e Merge branch 'develop' of https://github.com/shlinkio/shlink into develop 2016-08-21 19:16:21 +02:00
Alejandro Celaya
cbb5a02b95 Kept symlincs while generating the dist file 2016-08-21 19:16:06 +02:00
Alejandro Celaya
7aa42ada54 Merge pull request #61 from acelaya/develop
Develop
2016-08-21 18:31:32 +02:00
38 changed files with 955 additions and 84 deletions

1
.gitignore vendored
View File

@@ -4,3 +4,4 @@ composer.lock
vendor/
.env
data/database.sqlite
docs/swagger-ui

View File

@@ -1,5 +1,33 @@
## CHANGELOG
### 1.3.0
**Enhancements:**
* [67: Allow to order the short codes list](https://github.com/acelaya/url-shortener/issues/67)
* [60: Accept JSON requests in REST and use a body parser middleware to set the parsedBody](https://github.com/acelaya/url-shortener/issues/60)
* [72: When listing API keys from CLI, display in yellow color enabled keys that have expired](https://github.com/acelaya/url-shortener/issues/72)
* [58: Allow to filter short URLs by tag](https://github.com/acelaya/url-shortener/issues/58)
* [69: Allow to filter short codes by text query](https://github.com/acelaya/url-shortener/issues/69)
**Tasks**
* [73: Tag endpoints in swagger file](https://github.com/acelaya/url-shortener/issues/73)
* [71: Separate swagger docs into multiple files](https://github.com/acelaya/url-shortener/issues/71)
* [63: Add path versioning to REST API routes](https://github.com/acelaya/url-shortener/issues/63)
### 1.2.2
**Bugs**
* Fixed minor bugs on CORS requests
### 1.2.1
**Bugs**
* [62: Fix cross-domain requests in REST API](https://github.com/acelaya/url-shortener/issues/62)
### 1.2.0
**Features**

View File

@@ -40,5 +40,5 @@ rm -rf config/autoload/{{,*.}local.php{,.dist},.gitignore}
# Compressing file
rm -f "${projectdir}"/build/shlink_${version}_dist.zip
zip -r "${projectdir}"/build/shlink_${version}_dist.zip .
zip -ry "${projectdir}"/build/shlink_${version}_dist.zip .
rm -rf "${builtcontent}"

View File

@@ -42,7 +42,8 @@
"roave/security-advisories": "dev-master",
"filp/whoops": "^2.0",
"symfony/var-dumper": "^3.0",
"vlucas/phpdotenv": "^2.2"
"vlucas/phpdotenv": "^2.2",
"phly/changelog-generator": "^2.1"
},
"autoload": {
"psr-4": {
@@ -73,5 +74,8 @@
"serve": "php -S 0.0.0.0:8000 -t public/",
"test": "phpunit --coverage-clover build/clover.xml",
"pretty-test": "phpunit --coverage-html build/coverage"
},
"config": {
"process-timeout": 0
}
}

View File

@@ -0,0 +1,13 @@
{
"type": "object",
"properties": {
"code": {
"type": "string",
"description": "A machine unique code"
},
"message": {
"type": "string",
"description": "A human-friendly error message"
}
}
}

View File

@@ -0,0 +1,13 @@
{
"type": "object",
"properties": {
"currentPage": {
"type": "integer",
"description": "The number of current page being displayed."
},
"pagesCount": {
"type": "integer",
"description": "The total number of pages that can be displayed."
}
}
}

View File

@@ -0,0 +1,29 @@
{
"type": "object",
"properties": {
"shortCode": {
"type": "string",
"description": "The short code for this short URL."
},
"originalUrl": {
"type": "string",
"description": "The original long URL."
},
"dateCreated": {
"type": "string",
"format": "date-time",
"description": "The date in which the short URL was created in ISO format."
},
"visitsCount": {
"type": "integer",
"description": "The number of visits that this short URL has recieved."
},
"tags": {
"type": "array",
"items": {
"type": "string"
},
"description": "A list of tags applied to this short URL"
}
}
}

View File

@@ -0,0 +1,18 @@
{
"type": "object",
"properties": {
"referer": {
"type": "string"
},
"date": {
"type": "string",
"format": "date-time"
},
"remoteAddr": {
"type": "string"
},
"userAgent": {
"type": "string"
}
}
}

View File

@@ -0,0 +1,7 @@
{
"name": "Authorization",
"in": "header",
"description": "The authorization token with Bearer type",
"required": true,
"type": "string"
}

View File

@@ -0,0 +1,50 @@
{
"post": {
"tags": [
"Authentication"
],
"summary": "Perform authentication",
"description": "Performs an authentication",
"parameters": [
{
"name": "apiKey",
"in": "formData",
"description": "The API key to authenticate with",
"required": true,
"type": "string"
}
],
"responses": {
"200": {
"description": "The authentication worked.",
"schema": {
"type": "object",
"properties": {
"token": {
"type": "string",
"description": "The authentication token that needs to be sent in the Authorization header"
}
}
}
},
"400": {
"description": "An API key was not provided.",
"schema": {
"$ref": "../definitions/Error.json"
}
},
"401": {
"description": "The API key is incorrect, is disabled or has expired.",
"schema": {
"$ref": "../definitions/Error.json"
}
},
"500": {
"description": "Unexpected error.",
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
}
}

View File

@@ -0,0 +1,146 @@
{
"get": {
"tags": [
"ShortCodes"
],
"summary": "List short URLs",
"description": "Returns the list of short codes",
"parameters": [
{
"name": "page",
"in": "query",
"description": "The page to be displayed. Defaults to 1",
"required": false,
"type": "integer"
},
{
"name": "searchTerm",
"in": "query",
"description": "A query used to filter results by searching for it on the longUrl and shortCode fields. (Since v1.3.0)",
"required": false,
"type": "string"
},
{
"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,
"type": "array",
"schema": {
"items": {
"type": "string"
}
}
},
{
"name": "orderBy",
"in": "query",
"description": "The field from which you want to order the result. (Since v1.3.0)",
"enum": [
"originalUrl",
"shortCode",
"dateCreated",
"visits"
],
"required": false,
"type": "string"
},
{
"$ref": "../parameters/Authorization.json"
}
],
"responses": {
"200": {
"description": "The list of short URLs",
"schema": {
"type": "object",
"properties": {
"shortUrls": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "../definitions/ShortUrl.json"
}
},
"pagination": {
"$ref": "../definitions/Pagination.json"
}
}
}
}
}
},
"500": {
"description": "Unexpected error.",
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
},
"post": {
"tags": [
"ShortCodes"
],
"summary": "Create short URL",
"description": "Creates a new short code",
"parameters": [
{
"name": "longUrl",
"in": "formData",
"description": "The URL to parse",
"required": true,
"type": "string"
},
{
"name": "tags",
"in": "formData",
"description": "The URL to parse",
"required": false,
"type": "array",
"items": {
"type": "string"
}
},
{
"$ref": "../parameters/Authorization.json"
}
],
"responses": {
"200": {
"description": "The result of parsing the long URL",
"schema": {
"type": "object",
"properties": {
"longUrl": {
"type": "string",
"description": "The original long URL that has been parsed"
},
"shortUrl": {
"type": "string",
"description": "The generated short URL"
},
"shortCode": {
"type": "string",
"description": "the short code that is being used in the short URL"
}
}
}
},
"400": {
"description": "The long URL was not provided or is invalid.",
"schema": {
"$ref": "../definitions/Error.json"
}
},
"500": {
"description": "Unexpected error.",
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
}
}

View File

@@ -0,0 +1,53 @@
{
"get": {
"tags": [
"ShortCodes"
],
"summary": "Parse short code",
"description": "Get the long URL behind a short code.",
"parameters": [
{
"name": "shortCode",
"in": "path",
"type": "string",
"description": "The short code to resolve.",
"required": true
},
{
"$ref": "../parameters/Authorization.json"
}
],
"responses": {
"200": {
"description": "The long URL behind a short code.",
"schema": {
"type": "object",
"properties": {
"longUrl": {
"type": "string",
"description": "The original long URL behind the short code."
}
}
}
},
"400": {
"description": "Provided shortCode does not match the character set currently used by the app to generate short codes.",
"schema": {
"$ref": "../definitions/Error.json"
}
},
"404": {
"description": "No URL was found for provided short code.",
"schema": {
"$ref": "../definitions/Error.json"
}
},
"500": {
"description": "Unexpected error.",
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
}
}

View File

@@ -0,0 +1,66 @@
{
"put": {
"tags": [
"ShortCodes",
"Tags"
],
"summary": "Edit tags on short URL",
"description": "Edit the tags on provided short code.",
"parameters": [
{
"name": "shortCode",
"in": "path",
"type": "string",
"description": "The shortCode in which we want to edit tags.",
"required": true
},
{
"name": "tags",
"in": "formData",
"type": "array",
"items": {
"type": "string"
},
"description": "The list of tags to set to the short URL.",
"required": true
},
{
"$ref": "../parameters/Authorization.json"
}
],
"responses": {
"200": {
"description": "List of tags.",
"schema": {
"type": "object",
"properties": {
"tags": {
"type": "array",
"items": {
"type": "string"
}
}
}
}
},
"400": {
"description": "The request body does not contain a \"tags\" param with array type.",
"schema": {
"$ref": "../definitions/Error.json"
}
},
"404": {
"description": "No short URL was found for provided short code.",
"schema": {
"$ref": "../definitions/Error.json"
}
},
"500": {
"description": "Unexpected error.",
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
}
}

View File

@@ -0,0 +1,55 @@
{
"get": {
"tags": [
"ShortCodes",
"Visits"
],
"summary": "List visits for short URL",
"description": "Get the list of visits on provided short code.",
"parameters": [
{
"name": "shortCode",
"in": "path",
"type": "string",
"description": "The shortCode from which we want to get the visits.",
"required": true
},
{
"$ref": "../parameters/Authorization.json"
}
],
"responses": {
"200": {
"description": "List of visits.",
"schema": {
"type": "object",
"properties": {
"visits": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "../definitions/Visit.json"
}
}
}
}
}
}
},
"404": {
"description": "The short code does not belong to any short URL.",
"schema": {
"$ref": "../definitions/Error.json"
}
},
"500": {
"description": "Unexpected error.",
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
}
}

38
docs/swagger/swagger.json Normal file
View File

@@ -0,0 +1,38 @@
{
"swagger": "2.0",
"info": {
"title": "Shlink",
"description": "Shlink, the self-hosted URL shortener",
"version": "1.2.0"
},
"schemes": [
"http",
"https"
],
"basePath": "/rest",
"produces": [
"application/json"
],
"consumes": [
"application/x-www-form-urlencoded",
"application/json"
],
"paths": {
"/v1/authenticate": {
"$ref": "paths/v1_authenticate.json"
},
"/v1/short-codes": {
"$ref": "paths/v1_short-codes.json"
},
"/v1/short-codes/{shortCode}": {
"$ref": "paths/v1_short-codes_{shortCode}.json"
},
"/v1/short-codes/{shortCode}/visits": {
"$ref": "paths/v1_short-codes_{shortCode}_visits.json"
},
"/v1/short-codes/{shortCode}/tags": {
"$ref": "paths/v1_short-codes_{shortCode}_tags.json"
}
}
}

Binary file not shown.

View File

@@ -1,8 +1,8 @@
msgid ""
msgstr ""
"Project-Id-Version: Shlink 1.0\n"
"POT-Creation-Date: 2016-08-21 18:16+0200\n"
"PO-Revision-Date: 2016-08-21 18:16+0200\n"
"POT-Creation-Date: 2016-10-22 23:12+0200\n"
"PO-Revision-Date: 2016-10-22 23:13+0200\n"
"Last-Translator: Alejandro Celaya <alejandro@alejandrocelaya.com>\n"
"Language-Team: \n"
"Language: es_ES\n"
@@ -162,6 +162,22 @@ msgstr "Listar todas las URLs cortas"
msgid "The first page to list (%s items per page)"
msgstr "La primera página a listar (%s elementos por página)"
msgid ""
"A query used to filter results by searching for it on the longUrl and "
"shortCode fields"
msgstr ""
"Una consulta usada para filtrar el resultado buscándola en los campos "
"longUrl y shortCode"
msgid "A comma-separated list of tags to filter results"
msgstr "Una lista de etiquetas separadas por coma para filtrar el resultado"
msgid ""
"The field from which we want to order by. Pass ASC or DESC separated by a "
"comma"
msgstr ""
"El campo por el cual queremos ordernar. Pasa ASC o DESC separado por una coma"
msgid "Whether to display the tags or not"
msgstr "Si se desea mostrar las etiquetas o no"

View File

@@ -73,12 +73,15 @@ class ListKeysCommand extends Command
$key = $row->getKey();
$expiration = $row->getExpirationDate();
$rowData = [];
$formatMethod = ! $row->isEnabled()
? 'getErrorString'
: ($row->isExpired() ? 'getWarningString' : 'getSuccessString');
if ($enabledOnly) {
$rowData[] = $key;
$rowData[] = $this->{$formatMethod}($key);
} else {
$rowData[] = $row->isEnabled() ? $this->getSuccessString($key) : $this->getErrorString($key);
$rowData[] = $row->isEnabled() ? $this->getSuccessString('+++') : $this->getErrorString('---');
$rowData[] = $this->{$formatMethod}($key);
$rowData[] = $this->{$formatMethod}($this->getEnabledSymbol($row));
}
$rowData[] = isset($expiration) ? $expiration->format(\DateTime::ISO8601) : '-';
@@ -105,4 +108,22 @@ class ListKeysCommand extends Command
{
return sprintf('<info>%s</info>', $string);
}
/**
* @param $string
* @return string
*/
protected function getWarningString($string)
{
return sprintf('<comment>%s</comment>', $string);
}
/**
* @param ApiKey $apiKey
* @return string
*/
protected function getEnabledSymbol(ApiKey $apiKey)
{
return ! $apiKey->isEnabled() || $apiKey->isExpired() ? '---' : '+++';
}
}

View File

@@ -56,9 +56,31 @@ class ListShortcodesCommand extends Command
),
1
)
->addOption(
'searchTerm',
's',
InputOption::VALUE_OPTIONAL,
$this->translator->translate(
'A query used to filter results by searching for it on the longUrl and shortCode fields'
)
)
->addOption(
'tags',
't',
InputOption::VALUE_OPTIONAL,
$this->translator->translate('A comma-separated list of tags to filter results')
)
->addOption(
'orderBy',
'o',
InputOption::VALUE_OPTIONAL,
$this->translator->translate(
'The field from which we want to order by. Pass ASC or DESC separated by a comma'
)
)
->addOption(
'showTags',
null,
InputOption::VALUE_NONE,
$this->translator->translate('Whether to display the tags or not')
);
@@ -67,13 +89,17 @@ class ListShortcodesCommand extends Command
public function execute(InputInterface $input, OutputInterface $output)
{
$page = intval($input->getOption('page'));
$showTags = $input->getOption('tags');
$searchTerm = $input->getOption('searchTerm');
$tags = $input->getOption('tags');
$tags = ! empty($tags) ? explode(',', $tags) : [];
$showTags = $input->getOption('showTags');
$orderBy = $input->getOption('orderBy');
/** @var QuestionHelper $helper */
$helper = $this->getHelper('question');
do {
$result = $this->shortUrlService->listShortUrls($page);
$result = $this->shortUrlService->listShortUrls($page, $searchTerm, $tags, $this->processOrderBy($input));
$page++;
$table = new Table($output);
@@ -119,4 +145,15 @@ class ListShortcodesCommand extends Command
}
} while ($continue);
}
protected function processOrderBy(InputInterface $input)
{
$orderBy = $input->getOption('orderBy');
if (empty($orderBy)) {
return null;
}
$orderBy = explode(',', $orderBy);
return count($orderBy) === 1 ? $orderBy[0] : [$orderBy[0] => $orderBy[1]];
}
}

View File

@@ -46,8 +46,8 @@ class ListShortcodesCommandTest extends TestCase
public function noInputCallsListJustOnce()
{
$this->questionHelper->setInputStream($this->getInputStream('\n'));
$this->shortUrlService->listShortUrls(1)->willReturn(new Paginator(new ArrayAdapter()))
->shouldBeCalledTimes(1);
$this->shortUrlService->listShortUrls(1, null, [], null)->willReturn(new Paginator(new ArrayAdapter()))
->shouldBeCalledTimes(1);
$this->commandTester->execute(['command' => 'shortcode:list']);
}
@@ -66,7 +66,11 @@ class ListShortcodesCommandTest extends TestCase
$questionHelper = $this->questionHelper;
$that = $this;
$this->shortUrlService->listShortUrls(Argument::any())->will(function () use (&$data, $questionHelper, $that) {
$this->shortUrlService->listShortUrls(Argument::cetera())->will(function () use (
&$data,
$questionHelper,
$that
) {
$questionHelper->setInputStream($that->getInputStream('y'));
return new Paginator(new ArrayAdapter(array_shift($data)));
})->shouldBeCalledTimes(3);
@@ -86,8 +90,8 @@ class ListShortcodesCommandTest extends TestCase
}
$this->questionHelper->setInputStream($this->getInputStream('n'));
$this->shortUrlService->listShortUrls(Argument::any())->willReturn(new Paginator(new ArrayAdapter($data)))
->shouldBeCalledTimes(1);
$this->shortUrlService->listShortUrls(Argument::cetera())->willReturn(new Paginator(new ArrayAdapter($data)))
->shouldBeCalledTimes(1);
$this->commandTester->execute(['command' => 'shortcode:list']);
}
@@ -99,8 +103,8 @@ class ListShortcodesCommandTest extends TestCase
{
$page = 5;
$this->questionHelper->setInputStream($this->getInputStream('\n'));
$this->shortUrlService->listShortUrls($page)->willReturn(new Paginator(new ArrayAdapter()))
->shouldBeCalledTimes(1);
$this->shortUrlService->listShortUrls($page, null, [], null)->willReturn(new Paginator(new ArrayAdapter()))
->shouldBeCalledTimes(1);
$this->commandTester->execute([
'command' => 'shortcode:list',
@@ -114,12 +118,12 @@ class ListShortcodesCommandTest extends TestCase
public function ifTagsFlagIsProvidedTagsColumnIsIncluded()
{
$this->questionHelper->setInputStream($this->getInputStream('\n'));
$this->shortUrlService->listShortUrls(1)->willReturn(new Paginator(new ArrayAdapter()))
->shouldBeCalledTimes(1);
$this->shortUrlService->listShortUrls(1, null, [], null)->willReturn(new Paginator(new ArrayAdapter()))
->shouldBeCalledTimes(1);
$this->commandTester->execute([
'command' => 'shortcode:list',
'--tags' => true,
'--showTags' => true,
]);
$output = $this->commandTester->getDisplay();
$this->assertTrue(strpos($output, 'Tags') > 0);

View File

@@ -13,19 +13,28 @@ class PaginableRepositoryAdapter implements AdapterInterface
*/
private $paginableRepository;
/**
* @var null
* @var null|string
*/
private $searchTerm;
/**
* @var null
* @var null|array|string
*/
private $orderBy;
/**
* @var array
*/
private $tags;
public function __construct(PaginableRepositoryInterface $paginableRepository, $searchTerm = null, $orderBy = null)
{
public function __construct(
PaginableRepositoryInterface $paginableRepository,
$searchTerm = null,
array $tags = [],
$orderBy = null
) {
$this->paginableRepository = $paginableRepository;
$this->searchTerm = $searchTerm;
$this->searchTerm = trim(strip_tags($searchTerm));
$this->orderBy = $orderBy;
$this->tags = $tags;
}
/**
@@ -37,7 +46,13 @@ class PaginableRepositoryAdapter implements AdapterInterface
*/
public function getItems($offset, $itemCountPerPage)
{
return $this->paginableRepository->findList($itemCountPerPage, $offset, $this->searchTerm, $this->orderBy);
return $this->paginableRepository->findList(
$itemCountPerPage,
$offset,
$this->searchTerm,
$this->tags,
$this->orderBy
);
}
/**
@@ -51,6 +66,6 @@ class PaginableRepositoryAdapter implements AdapterInterface
*/
public function count()
{
return $this->paginableRepository->countList($this->searchTerm);
return $this->paginableRepository->countList($this->searchTerm, $this->tags);
}
}

View File

@@ -9,16 +9,18 @@ interface PaginableRepositoryInterface
* @param int|null $limit
* @param int|null $offset
* @param string|null $searchTerm
* @param array $tags
* @param string|array|null $orderBy
* @return array
*/
public function findList($limit = null, $offset = null, $searchTerm = null, $orderBy = null);
public function findList($limit = null, $offset = null, $searchTerm = null, array $tags = [], $orderBy = null);
/**
* Counts the number of elements in a list using provided filtering data
*
* @param null $searchTerm
* @param array $tags
* @return int
*/
public function countList($searchTerm = null);
public function countList($searchTerm = null, array $tags = []);
}

View File

@@ -20,7 +20,7 @@ class PaginableRepositoryAdapterTest extends TestCase
public function setUp()
{
$this->repo = $this->prophesize(PaginableRepositoryInterface::class);
$this->adapter = new PaginableRepositoryAdapter($this->repo->reveal(), 'search', 'order');
$this->adapter = new PaginableRepositoryAdapter($this->repo->reveal(), 'search', ['foo', 'bar'], 'order');
}
/**
@@ -28,7 +28,7 @@ class PaginableRepositoryAdapterTest extends TestCase
*/
public function getItemsFallbacksToFindList()
{
$this->repo->findList(10, 5, 'search', 'order')->shouldBeCalledTimes(1);
$this->repo->findList(10, 5, 'search', ['foo', 'bar'], 'order')->shouldBeCalledTimes(1);
$this->adapter->getItems(5, 10);
}
@@ -37,7 +37,7 @@ class PaginableRepositoryAdapterTest extends TestCase
*/
public function countFallbacksToCountList()
{
$this->repo->countList('search')->shouldBeCalledTimes(1);
$this->repo->countList('search', ['foo', 'bar'])->shouldBeCalledTimes(1);
$this->adapter->count();
}
}

View File

@@ -2,6 +2,7 @@
namespace Shlinkio\Shlink\Core\Repository;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\QueryBuilder;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryInterface
@@ -10,31 +11,55 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
* @param int|null $limit
* @param int|null $offset
* @param string|null $searchTerm
* @param array $tags
* @param string|array|null $orderBy
* @return ShortUrl[]
* @return \Shlinkio\Shlink\Core\Entity\ShortUrl[]
*/
public function findList($limit = null, $offset = null, $searchTerm = null, $orderBy = null)
public function findList($limit = null, $offset = null, $searchTerm = null, array $tags = [], $orderBy = null)
{
$qb = $this->createQueryBuilder('s');
$qb = $this->createListQueryBuilder($searchTerm, $tags);
$qb->select('s');
// Set limit and offset
if (isset($limit)) {
$qb->setMaxResults($limit);
}
if (isset($offset)) {
$qb->setFirstResult($offset);
}
if (isset($searchTerm)) {
// TODO
}
// In case the ordering has been specified, the query could be more complex. Process it
if (isset($orderBy)) {
if (is_string($orderBy)) {
$qb->orderBy($orderBy);
} elseif (is_array($orderBy)) {
$key = key($orderBy);
$qb->orderBy($key, $orderBy[$key]);
}
} else {
$qb->orderBy('s.dateCreated');
return $this->processOrderByForList($qb, $orderBy);
}
// With no order by, order by date and just return the list of ShortUrls
$qb->orderBy('s.dateCreated');
return $qb->getQuery()->getResult();
}
protected function processOrderByForList(QueryBuilder $qb, $orderBy)
{
$fieldName = is_array($orderBy) ? key($orderBy) : $orderBy;
$order = is_array($orderBy) ? $orderBy[$fieldName] : 'ASC';
if (in_array($fieldName, [
'visits',
'visitsCount',
'visitCount',
])) {
$qb->addSelect('COUNT(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',
])) {
$qb->orderBy('s.' . $fieldName, $order);
}
return $qb->getQuery()->getResult();
@@ -43,19 +68,48 @@ class ShortUrlRepository extends EntityRepository implements ShortUrlRepositoryI
/**
* Counts the number of elements in a list using provided filtering data
*
* @param null $searchTerm
* @param null|string $searchTerm
* @param array $tags
* @return int
*/
public function countList($searchTerm = null)
public function countList($searchTerm = null, array $tags = [])
{
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->select('COUNT(s)')
->from(ShortUrl::class, 's');
if (isset($searchTerm)) {
// TODO
}
$qb = $this->createListQueryBuilder($searchTerm, $tags);
$qb->select('COUNT(s)');
return (int) $qb->getQuery()->getSingleScalarResult();
}
/**
* @param null|string $searchTerm
* @param array $tags
* @return QueryBuilder
*/
protected function createListQueryBuilder($searchTerm = null, array $tags = [])
{
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->from(ShortUrl::class, 's');
$qb->where('1=1');
// Apply search term to every searchable field if not empty
if (! empty($searchTerm)) {
$conditions = [
$qb->expr()->like('s.originalUrl', ':searchPattern'),
$qb->expr()->like('s.shortCode', ':searchPattern'),
];
// Unpack and apply search conditions
$qb->andWhere($qb->expr()->orX(...$conditions));
$searchTerm = '%' . $searchTerm . '%';
$qb->setParameter('searchPattern', $searchTerm);
}
// Filter by tags if provided
if (! empty($tags)) {
$qb->join('s.tags', 't')
->andWhere($qb->expr()->in('t.name', $tags));
}
return $qb;
}
}

View File

@@ -29,12 +29,6 @@ class VisitRepository extends EntityRepository implements VisitRepositoryInterfa
$shortUrl = $shortUrl instanceof ShortUrl
? $shortUrl
: $this->getEntityManager()->find(ShortUrl::class, $shortUrl);
if (! isset($dateRange) || $dateRange->isEmpty()) {
$startDate = $shortUrl->getDateCreated();
$endDate = clone $startDate;
$endDate->add(new \DateInterval('P2D'));
$dateRange = new DateRange($startDate, $endDate);
}
$qb = $this->createQueryBuilder('v');
$qb->where($qb->expr()->eq('v.shortUrl', ':shortUrl'))

View File

@@ -32,13 +32,16 @@ class ShortUrlService implements ShortUrlServiceInterface
/**
* @param int $page
* @return Paginator|ShortUrl[]
* @param string $searchQuery
* @param array $tags
* @param null $orderBy
* @return ShortUrl[]|Paginator
*/
public function listShortUrls($page = 1)
public function listShortUrls($page = 1, $searchQuery = null, array $tags = [], $orderBy = null)
{
/** @var ShortUrlRepository $repo */
$repo = $this->em->getRepository(ShortUrl::class);
$paginator = new Paginator(new PaginableRepositoryAdapter($repo));
$paginator = new Paginator(new PaginableRepositoryAdapter($repo, $searchQuery, $tags, $orderBy));
$paginator->setItemCountPerPage(PaginableRepositoryAdapter::ITEMS_PER_PAGE)
->setCurrentPageNumber($page);

View File

@@ -9,9 +9,12 @@ interface ShortUrlServiceInterface
{
/**
* @param int $page
* @param string $searchQuery
* @param array $tags
* @param null $orderBy
* @return ShortUrl[]|Paginator
*/
public function listShortUrls($page = 1);
public function listShortUrls($page = 1, $searchQuery = null, array $tags = [], $orderBy = null);
/**
* @param string $shortCode

View File

@@ -22,6 +22,7 @@ return [
Middleware\BodyParserMiddleware::class => AnnotatedFactory::class,
Middleware\CrossDomainMiddleware::class => InvokableFactory::class,
Middleware\PathVersionMiddleware::class => InvokableFactory::class,
Middleware\CheckAuthenticationMiddleware::class => AnnotatedFactory::class,
],
],

View File

@@ -4,12 +4,19 @@ use Shlinkio\Shlink\Rest\Middleware;
return [
'middleware_pipeline' => [
'pre-routing' => [
'middleware' => [
Middleware\PathVersionMiddleware::class,
],
'priority' => 11,
],
'rest' => [
'path' => '/rest',
'middleware' => [
Middleware\CrossDomainMiddleware::class,
Middleware\BodyParserMiddleware::class,
Middleware\CheckAuthenticationMiddleware::class,
Middleware\CrossDomainMiddleware::class,
],
'priority' => 5,
],

View File

@@ -6,37 +6,37 @@ return [
'routes' => [
[
'name' => 'rest-authenticate',
'path' => '/rest/authenticate',
'path' => '/rest/v{version:1}/authenticate',
'middleware' => Action\AuthenticateAction::class,
'allowed_methods' => ['POST', 'OPTIONS'],
],
[
'name' => 'rest-create-shortcode',
'path' => '/rest/short-codes',
'path' => '/rest/v{version:1}/short-codes',
'middleware' => Action\CreateShortcodeAction::class,
'allowed_methods' => ['POST', 'OPTIONS'],
],
[
'name' => 'rest-resolve-url',
'path' => '/rest/short-codes/{shortCode}',
'path' => '/rest/v{version:1}/short-codes/{shortCode}',
'middleware' => Action\ResolveUrlAction::class,
'allowed_methods' => ['GET', 'OPTIONS'],
],
[
'name' => 'rest-list-shortened-url',
'path' => '/rest/short-codes',
'path' => '/rest/v{version:1}/short-codes',
'middleware' => Action\ListShortcodesAction::class,
'allowed_methods' => ['GET'],
],
[
'name' => 'rest-get-visits',
'path' => '/rest/short-codes/{shortCode}/visits',
'path' => '/rest/v{version:1}/short-codes/{shortCode}/visits',
'middleware' => Action\GetVisitsAction::class,
'allowed_methods' => ['GET', 'OPTIONS'],
],
[
'name' => 'rest-edit-tags',
'path' => '/rest/short-codes/{shortCode}/tags',
'path' => '/rest/v{version:1}/short-codes/{shortCode}/tags',
'middleware' => Action\EditTagsAction::class,
'allowed_methods' => ['PUT', 'OPTIONS'],
],

View File

@@ -66,7 +66,7 @@ class AuthenticateAction extends AbstractRestAction
// Authenticate using provided API key
$apiKey = $this->apiKeyService->getByKey($authData['apiKey']);
if (! $apiKey->isValid()) {
if (! isset($apiKey) || ! $apiKey->isValid()) {
return new JsonResponse([
'error' => RestUtils::INVALID_API_KEY_ERROR,
'message' => $this->translator->translate('Provided API key does not exist or is invalid.'),

View File

@@ -53,8 +53,8 @@ class ListShortcodesAction extends AbstractRestAction
public function dispatch(Request $request, Response $response, callable $out = null)
{
try {
$query = $request->getQueryParams();
$shortUrls = $this->shortUrlService->listShortUrls(isset($query['page']) ? $query['page'] : 1);
$params = $this->queryToListParams($request->getQueryParams());
$shortUrls = $this->shortUrlService->listShortUrls(...$params);
return new JsonResponse(['shortUrls' => $this->serializePaginator($shortUrls)]);
} catch (\Exception $e) {
$this->logger->error('Unexpected error while listing short URLs.' . PHP_EOL . $e);
@@ -64,4 +64,18 @@ class ListShortcodesAction extends AbstractRestAction
], 500);
}
}
/**
* @param array $query
* @return string
*/
public function queryToListParams(array $query)
{
return [
isset($query['page']) ? $query['page'] : 1,
isset($query['searchTerm']) ? $query['searchTerm'] : null,
isset($query['tags']) ? $query['tags'] : [],
isset($query['orderBy']) ? $query['orderBy'] : null,
];
}
}

View File

@@ -3,6 +3,7 @@ namespace Shlinkio\Shlink\Rest\Middleware;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Shlinkio\Shlink\Common\Exception\RuntimeException;
use Zend\Stratigility\MiddlewareInterface;
class BodyParserMiddleware implements MiddlewareInterface
@@ -35,18 +36,66 @@ class BodyParserMiddleware implements MiddlewareInterface
public function __invoke(Request $request, Response $response, callable $out = null)
{
$method = $request->getMethod();
if (! in_array($method, ['PUT', 'PATCH'])) {
$currentParams = $request->getParsedBody();
// In requests that do not allow body or if the body has already been parsed, continue to next middleware
if (in_array($method, ['GET', 'HEAD', 'OPTIONS']) || ! empty($currentParams)) {
return $out($request, $response);
}
$contentType = $request->getHeaderLine('Content-type');
$rawBody = (string) $request->getBody();
// If the accepted content is JSON, try to parse the body from JSON
$contentType = $this->getRequestContentType($request);
if (in_array($contentType, ['application/json', 'text/json', 'application/x-json'])) {
return $out($request->withParsedBody(json_decode($rawBody, true)), $response);
return $out($this->parseFromJson($request), $response);
}
return $out($this->parseFromUrlEncoded($request), $response);
}
/**
* @param Request $request
* @return string
*/
protected function getRequestContentType(Request $request)
{
$contentType = $request->getHeaderLine('Content-type');
$contentTypes = explode(';', $contentType);
return trim(array_shift($contentTypes));
}
/**
* @param Request $request
* @return Request
*/
protected function parseFromJson(Request $request)
{
$rawBody = (string) $request->getBody();
if (empty($rawBody)) {
return $request;
}
$parsedJson = json_decode($rawBody, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new RuntimeException(sprintf('Error when parsing JSON request body: %s', json_last_error_msg()));
}
return $request->withParsedBody($parsedJson);
}
/**
* @param Request $request
* @return Request
*/
protected function parseFromUrlEncoded(Request $request)
{
$rawBody = (string) $request->getBody();
if (empty($rawBody)) {
return $request;
}
$parsedBody = [];
parse_str($rawBody, $parsedBody);
return $out($request->withParsedBody($parsedBody), $response);
return $request->withParsedBody($parsedBody);
}
}

View File

@@ -13,6 +13,7 @@ use Shlinkio\Shlink\Rest\Util\RestUtils;
use Zend\Diactoros\Response\JsonResponse;
use Zend\Expressive\Router\RouteResult;
use Zend\I18n\Translator\TranslatorInterface;
use Zend\Stdlib\ErrorHandler;
use Zend\Stratigility\MiddlewareInterface;
class CheckAuthenticationMiddleware implements MiddlewareInterface
@@ -117,9 +118,11 @@ class CheckAuthenticationMiddleware implements MiddlewareInterface
}
try {
ErrorHandler::start();
if (! $this->jwtService->verify($jwt)) {
return $this->createTokenErrorResponse();
}
ErrorHandler::stop(true);
// Update the token expiration and continue to next middleware
$jwt = $this->jwtService->refresh($jwt);
@@ -131,6 +134,14 @@ class CheckAuthenticationMiddleware implements MiddlewareInterface
} catch (AuthenticationException $e) {
$this->logger->warning('Tried to access API with an invalid JWT.' . PHP_EOL . $e);
return $this->createTokenErrorResponse();
} catch (\Exception $e) {
$this->logger->warning('Unexpected error occurred.' . PHP_EOL . $e);
return $this->createTokenErrorResponse();
} catch (\Throwable $e) {
$this->logger->warning('Unexpected error occurred.' . PHP_EOL . $e);
return $this->createTokenErrorResponse();
} finally {
ErrorHandler::clean();
}
}

View File

@@ -41,7 +41,8 @@ class CrossDomainMiddleware implements MiddlewareInterface
}
// Add Allow-Origin header
$response = $response->withHeader('Access-Control-Allow-Origin', $request->getHeader('Origin'));
$response = $response->withHeader('Access-Control-Allow-Origin', $request->getHeader('Origin'))
->withHeader('Access-Control-Expose-Headers', 'Authorization');
if ($request->getMethod() !== 'OPTIONS') {
return $response;
}

View File

@@ -0,0 +1,59 @@
<?php
namespace Shlinkio\Shlink\Rest\Middleware;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Zend\Stratigility\MiddlewareInterface;
class PathVersionMiddleware implements MiddlewareInterface
{
/**
* Process an incoming request and/or response.
*
* Accepts a server-side request and a response instance, and does
* something with them.
*
* If the response is not complete and/or further processing would not
* interfere with the work done in the middleware, or if the middleware
* wants to delegate to another process, it can use the `$out` callable
* if present.
*
* If the middleware does not return a value, execution of the current
* request is considered complete, and the response instance provided will
* be considered the response to return.
*
* Alternately, the middleware may return a response instance.
*
* Often, middleware will `return $out();`, with the assumption that a
* later middleware will return a response.
*
* @param Request $request
* @param Response $response
* @param null|callable $out
* @return null|Response
*/
public function __invoke(Request $request, Response $response, callable $out = null)
{
$uri = $request->getUri();
$path = $uri->getPath();
// Exclude non-rest route
if (strpos($path, '/rest') !== 0) {
return $out($request, $response);
}
// If the path does not begin with the version number, prepend v1 by default for retrocompatibility purposes
if (strpos($path, '/rest/v') !== 0) {
$parts = explode('/', $path);
// Remove the first empty part and the "/rest" prefix
array_shift($parts);
array_shift($parts);
// Prepend the prefix with version
array_unshift($parts, '/rest/v1');
$request = $request->withUri($uri->withPath(implode('/', $parts)));
}
return $out($request, $response);
}
}

View File

@@ -34,8 +34,8 @@ class ListShortcodesActionTest extends TestCase
public function properListReturnsSuccessResponse()
{
$page = 3;
$this->service->listShortUrls($page)->willReturn(new Paginator(new ArrayAdapter()))
->shouldBeCalledTimes(1);
$this->service->listShortUrls($page, null, [], null)->willReturn(new Paginator(new ArrayAdapter()))
->shouldBeCalledTimes(1);
$response = $this->action->__invoke(
ServerRequestFactory::fromGlobals()->withQueryParams([
@@ -52,8 +52,8 @@ class ListShortcodesActionTest extends TestCase
public function anExceptionsReturnsErrorResponse()
{
$page = 3;
$this->service->listShortUrls($page)->willThrow(\Exception::class)
->shouldBeCalledTimes(1);
$this->service->listShortUrls($page, null, [], null)->willThrow(\Exception::class)
->shouldBeCalledTimes(1);
$response = $this->action->__invoke(
ServerRequestFactory::fromGlobals()->withQueryParams([

View File

@@ -0,0 +1,59 @@
<?php
namespace ShlinkioTest\Shlink\Rest\Middleware;
use PHPUnit_Framework_TestCase as TestCase;
use Psr\Http\Message\ServerRequestInterface as Request;
use Shlinkio\Shlink\Rest\Middleware\PathVersionMiddleware;
use Zend\Diactoros\Response;
use Zend\Diactoros\ServerRequestFactory;
use Zend\Diactoros\Uri;
class PathVersionMiddlewareTest extends TestCase
{
/**
* @var PathVersionMiddleware
*/
protected $middleware;
public function setUp()
{
$this->middleware = new PathVersionMiddleware();
}
/**
* @test
*/
public function whenVersionIsProvidedRequestRemainsUnchanged()
{
$request = ServerRequestFactory::fromGlobals()->withUri(new Uri('/rest/v2/foo'));
$test = $this;
$this->middleware->__invoke($request, new Response(), function ($req) use ($request, $test) {
$test->assertSame($request, $req);
});
}
/**
* @test
*/
public function versionOneIsPrependedWhenNoVersionIsDefined()
{
$request = ServerRequestFactory::fromGlobals()->withUri(new Uri('/rest/bar/baz'));
$test = $this;
$this->middleware->__invoke($request, new Response(), function (Request $req) use ($request, $test) {
$test->assertNotSame($request, $req);
$this->assertEquals('/rest/v1/bar/baz', $req->getUri()->getPath());
});
}
/**
* @test
*/
public function nonRestPathsAreNotProcessed()
{
$request = ServerRequestFactory::fromGlobals()->withUri(new Uri('/non-rest'));
$test = $this;
$this->middleware->__invoke($request, new Response(), function ($req) use ($request, $test) {
$test->assertSame($request, $req);
});
}
}