diff --git a/.gitignore b/.gitignore index aebab397..2a7fb730 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ composer.lock vendor/ .env data/database.sqlite +docs/swagger-ui diff --git a/CHANGELOG.md b/CHANGELOG.md index 643f085f..91b5a74e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ ## 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** diff --git a/composer.json b/composer.json index 58e9d40d..13947ddf 100644 --- a/composer.json +++ b/composer.json @@ -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": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml deleted file mode 100644 index 27037fe5..00000000 --- a/docs/swagger.yaml +++ /dev/null @@ -1,289 +0,0 @@ -swagger: '2.0' -info: - title: Shlink - description: Shlink, the self-hosted URL shortener - version: "1.2.0" - -schemes: - - https - -basePath: /rest -produces: - - application/json - -paths: - /authenticate: - post: - 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' - 401: - description: The API key is incorrect, is disabled or has expired. - schema: - $ref: '#/definitions/Error' - 500: - description: Unexpected error. - schema: - $ref: '#/definitions/Error' - /short-codes: - get: - 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: Authorization - in: header - description: The authorization token with Bearer type - required: true - type: string - responses: - 200: - description: The list of short URLs - schema: - type: object - properties: - shortUrls: - type: object - properties: - data: - type: array - items: - $ref: '#/definitions/ShortUrl' - pagination: - $ref: '#/definitions/Pagination' - 500: - description: Unexpected error. - schema: - $ref: '#/definitions/Error' - post: - 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 - - name: Authorization - in: header - description: The authorization token with Bearer type - required: true - type: string - 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' - 500: - description: Unexpected error. - schema: - $ref: '#/definitions/Error' - /short-codes/{shortCode}: - get: - description: Get the long URL behind a short code. - parameters: - - name: shortCode - in: path - type: string - description: The short code to resolve. - required: true - - name: Authorization - in: header - description: The authorization token with Bearer type - required: true - type: string - 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. - 404: - description: No URL was found for provided short code. - schema: - $ref: '#/definitions/Error' - 400: - description: Provided shortCode does not match the character set currently used by the app to generate short codes. - schema: - $ref: '#/definitions/Error' - 500: - description: Unexpected error. - schema: - $ref: '#/definitions/Error' - /short-codes/{shortCode}/visits: - get: - 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 - - name: Authorization - in: header - description: The authorization token with Bearer type - required: true - type: string - responses: - 200: - description: List of visits. - schema: - type: object - properties: - visits: - type: object - properties: - data: - type: array - items: - $ref: '#/definitions/Visit' - 404: - description: The short code does not belong to any short URL. - schema: - $ref: '#/definitions/Error' - 500: - description: Unexpected error. - schema: - $ref: '#/definitions/Error' - /short-codes/{shortCode}/tags: - put: - 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 - - name: Authorization - in: header - description: The authorization token with Bearer type - required: true - type: string - 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' - 404: - description: No short URL was found for provided short code. - schema: - $ref: '#/definitions/Error' - 500: - description: Unexpected error. - schema: - $ref: '#/definitions/Error' - -definitions: - ShortUrl: - 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 - - Visit: - type: object - properties: - referer: - type: string - date: - type: string - format: date-time - remoteAddr: - type: string - userAgent: - type: string - - Error: - type: object - properties: - code: - type: string - description: A machine unique code - message: - type: string - description: A human-friendly error message - - Pagination: - 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. diff --git a/docs/swagger/definitions/Error.json b/docs/swagger/definitions/Error.json new file mode 100644 index 00000000..3585227d --- /dev/null +++ b/docs/swagger/definitions/Error.json @@ -0,0 +1,13 @@ +{ + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "A machine unique code" + }, + "message": { + "type": "string", + "description": "A human-friendly error message" + } + } +} diff --git a/docs/swagger/definitions/Pagination.json b/docs/swagger/definitions/Pagination.json new file mode 100644 index 00000000..e9b731ef --- /dev/null +++ b/docs/swagger/definitions/Pagination.json @@ -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." + } + } +} diff --git a/docs/swagger/definitions/ShortUrl.json b/docs/swagger/definitions/ShortUrl.json new file mode 100644 index 00000000..6b1750b8 --- /dev/null +++ b/docs/swagger/definitions/ShortUrl.json @@ -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" + } + } +} diff --git a/docs/swagger/definitions/Visit.json b/docs/swagger/definitions/Visit.json new file mode 100644 index 00000000..8e0bc944 --- /dev/null +++ b/docs/swagger/definitions/Visit.json @@ -0,0 +1,18 @@ +{ + "type": "object", + "properties": { + "referer": { + "type": "string" + }, + "date": { + "type": "string", + "format": "date-time" + }, + "remoteAddr": { + "type": "string" + }, + "userAgent": { + "type": "string" + } + } +} diff --git a/docs/swagger/parameters/Authorization.json b/docs/swagger/parameters/Authorization.json new file mode 100644 index 00000000..54386de6 --- /dev/null +++ b/docs/swagger/parameters/Authorization.json @@ -0,0 +1,7 @@ +{ + "name": "Authorization", + "in": "header", + "description": "The authorization token with Bearer type", + "required": true, + "type": "string" +} diff --git a/docs/swagger/paths/v1_authenticate.json b/docs/swagger/paths/v1_authenticate.json new file mode 100644 index 00000000..843081bb --- /dev/null +++ b/docs/swagger/paths/v1_authenticate.json @@ -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" + } + } + } + } +} diff --git a/docs/swagger/paths/v1_short-codes.json b/docs/swagger/paths/v1_short-codes.json new file mode 100644 index 00000000..42b22f82 --- /dev/null +++ b/docs/swagger/paths/v1_short-codes.json @@ -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" + } + } + } + } +} diff --git a/docs/swagger/paths/v1_short-codes_{shortCode}.json b/docs/swagger/paths/v1_short-codes_{shortCode}.json new file mode 100644 index 00000000..a706f675 --- /dev/null +++ b/docs/swagger/paths/v1_short-codes_{shortCode}.json @@ -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" + } + } + } + } +} diff --git a/docs/swagger/paths/v1_short-codes_{shortCode}_tags.json b/docs/swagger/paths/v1_short-codes_{shortCode}_tags.json new file mode 100644 index 00000000..3cbfc952 --- /dev/null +++ b/docs/swagger/paths/v1_short-codes_{shortCode}_tags.json @@ -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" + } + } + } + } +} diff --git a/docs/swagger/paths/v1_short-codes_{shortCode}_visits.json b/docs/swagger/paths/v1_short-codes_{shortCode}_visits.json new file mode 100644 index 00000000..f90daf7a --- /dev/null +++ b/docs/swagger/paths/v1_short-codes_{shortCode}_visits.json @@ -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" + } + } + } + } +} diff --git a/docs/swagger/swagger.json b/docs/swagger/swagger.json new file mode 100644 index 00000000..6e9244f7 --- /dev/null +++ b/docs/swagger/swagger.json @@ -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" + } + } +} diff --git a/module/CLI/lang/es.mo b/module/CLI/lang/es.mo index 87678715..3365694a 100644 Binary files a/module/CLI/lang/es.mo and b/module/CLI/lang/es.mo differ diff --git a/module/CLI/lang/es.po b/module/CLI/lang/es.po index b2ac0670..dab5bdb7 100644 --- a/module/CLI/lang/es.po +++ b/module/CLI/lang/es.po @@ -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 \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" diff --git a/module/CLI/src/Command/Api/ListKeysCommand.php b/module/CLI/src/Command/Api/ListKeysCommand.php index e6f70ec0..5e93adeb 100644 --- a/module/CLI/src/Command/Api/ListKeysCommand.php +++ b/module/CLI/src/Command/Api/ListKeysCommand.php @@ -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('%s', $string); } + + /** + * @param $string + * @return string + */ + protected function getWarningString($string) + { + return sprintf('%s', $string); + } + + /** + * @param ApiKey $apiKey + * @return string + */ + protected function getEnabledSymbol(ApiKey $apiKey) + { + return ! $apiKey->isEnabled() || $apiKey->isExpired() ? '---' : '+++'; + } } diff --git a/module/CLI/src/Command/Shortcode/ListShortcodesCommand.php b/module/CLI/src/Command/Shortcode/ListShortcodesCommand.php index a3f77903..a8594db7 100644 --- a/module/CLI/src/Command/Shortcode/ListShortcodesCommand.php +++ b/module/CLI/src/Command/Shortcode/ListShortcodesCommand.php @@ -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]]; + } } diff --git a/module/CLI/test/Command/Shortcode/ListShortcodesCommandTest.php b/module/CLI/test/Command/Shortcode/ListShortcodesCommandTest.php index a426eb06..03ac0e0e 100644 --- a/module/CLI/test/Command/Shortcode/ListShortcodesCommandTest.php +++ b/module/CLI/test/Command/Shortcode/ListShortcodesCommandTest.php @@ -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); diff --git a/module/Common/src/Paginator/Adapter/PaginableRepositoryAdapter.php b/module/Common/src/Paginator/Adapter/PaginableRepositoryAdapter.php index ade0c0c9..995c7263 100644 --- a/module/Common/src/Paginator/Adapter/PaginableRepositoryAdapter.php +++ b/module/Common/src/Paginator/Adapter/PaginableRepositoryAdapter.php @@ -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); } } diff --git a/module/Common/src/Repository/PaginableRepositoryInterface.php b/module/Common/src/Repository/PaginableRepositoryInterface.php index b9a75aea..7481d186 100644 --- a/module/Common/src/Repository/PaginableRepositoryInterface.php +++ b/module/Common/src/Repository/PaginableRepositoryInterface.php @@ -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 = []); } diff --git a/module/Common/test/Paginator/PaginableRepositoryAdapterTest.php b/module/Common/test/Paginator/PaginableRepositoryAdapterTest.php index 1135682f..196cf0c9 100644 --- a/module/Common/test/Paginator/PaginableRepositoryAdapterTest.php +++ b/module/Common/test/Paginator/PaginableRepositoryAdapterTest.php @@ -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(); } } diff --git a/module/Core/src/Repository/ShortUrlRepository.php b/module/Core/src/Repository/ShortUrlRepository.php index e2d81cba..256cb985 100644 --- a/module/Core/src/Repository/ShortUrlRepository.php +++ b/module/Core/src/Repository/ShortUrlRepository.php @@ -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; + } } diff --git a/module/Core/src/Service/ShortUrlService.php b/module/Core/src/Service/ShortUrlService.php index 60845b88..59c76565 100644 --- a/module/Core/src/Service/ShortUrlService.php +++ b/module/Core/src/Service/ShortUrlService.php @@ -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); diff --git a/module/Core/src/Service/ShortUrlServiceInterface.php b/module/Core/src/Service/ShortUrlServiceInterface.php index 5ad304ee..bc9b8daf 100644 --- a/module/Core/src/Service/ShortUrlServiceInterface.php +++ b/module/Core/src/Service/ShortUrlServiceInterface.php @@ -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 diff --git a/module/Rest/config/dependencies.config.php b/module/Rest/config/dependencies.config.php index a6b51cb7..3fae9af7 100644 --- a/module/Rest/config/dependencies.config.php +++ b/module/Rest/config/dependencies.config.php @@ -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, ], ], diff --git a/module/Rest/config/middleware-pipeline.config.php b/module/Rest/config/middleware-pipeline.config.php index ce22dff1..8adea240 100644 --- a/module/Rest/config/middleware-pipeline.config.php +++ b/module/Rest/config/middleware-pipeline.config.php @@ -4,6 +4,13 @@ use Shlinkio\Shlink\Rest\Middleware; return [ 'middleware_pipeline' => [ + 'pre-routing' => [ + 'middleware' => [ + Middleware\PathVersionMiddleware::class, + ], + 'priority' => 11, + ], + 'rest' => [ 'path' => '/rest', 'middleware' => [ diff --git a/module/Rest/config/routes.config.php b/module/Rest/config/routes.config.php index d27b9c6d..8d107d8b 100644 --- a/module/Rest/config/routes.config.php +++ b/module/Rest/config/routes.config.php @@ -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'], ], diff --git a/module/Rest/src/Action/ListShortcodesAction.php b/module/Rest/src/Action/ListShortcodesAction.php index 1cd376bc..aa987c2f 100644 --- a/module/Rest/src/Action/ListShortcodesAction.php +++ b/module/Rest/src/Action/ListShortcodesAction.php @@ -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, + ]; + } } diff --git a/module/Rest/src/Middleware/BodyParserMiddleware.php b/module/Rest/src/Middleware/BodyParserMiddleware.php index 598f53ca..7344ccc4 100644 --- a/module/Rest/src/Middleware/BodyParserMiddleware.php +++ b/module/Rest/src/Middleware/BodyParserMiddleware.php @@ -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); } } diff --git a/module/Rest/src/Middleware/PathVersionMiddleware.php b/module/Rest/src/Middleware/PathVersionMiddleware.php new file mode 100644 index 00000000..b3eae299 --- /dev/null +++ b/module/Rest/src/Middleware/PathVersionMiddleware.php @@ -0,0 +1,54 @@ +getUri(); + $path = $uri->getPath(); + + // 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); + } +} diff --git a/module/Rest/test/Action/ListShortcodesActionTest.php b/module/Rest/test/Action/ListShortcodesActionTest.php index b5ec0c9d..d17740be 100644 --- a/module/Rest/test/Action/ListShortcodesActionTest.php +++ b/module/Rest/test/Action/ListShortcodesActionTest.php @@ -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([ diff --git a/module/Rest/test/Middleware/PathVersionMiddlewareTest.php b/module/Rest/test/Middleware/PathVersionMiddlewareTest.php new file mode 100644 index 00000000..098c4b01 --- /dev/null +++ b/module/Rest/test/Middleware/PathVersionMiddlewareTest.php @@ -0,0 +1,47 @@ +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()); + }); + } +}