mirror of
https://github.com/shlinkio/shlink.git
synced 2025-02-17 15:59:56 +03:00
Merge branch 'develop'
This commit is contained in:
commit
27b08ff47b
34 changed files with 913 additions and 363 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -4,3 +4,4 @@ composer.lock
|
|||
vendor/
|
||||
.env
|
||||
data/database.sqlite
|
||||
docs/swagger-ui
|
||||
|
|
22
CHANGELOG.md
22
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**
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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.
|
13
docs/swagger/definitions/Error.json
Normal file
13
docs/swagger/definitions/Error.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
13
docs/swagger/definitions/Pagination.json
Normal file
13
docs/swagger/definitions/Pagination.json
Normal 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."
|
||||
}
|
||||
}
|
||||
}
|
29
docs/swagger/definitions/ShortUrl.json
Normal file
29
docs/swagger/definitions/ShortUrl.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
18
docs/swagger/definitions/Visit.json
Normal file
18
docs/swagger/definitions/Visit.json
Normal file
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"referer": {
|
||||
"type": "string"
|
||||
},
|
||||
"date": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"remoteAddr": {
|
||||
"type": "string"
|
||||
},
|
||||
"userAgent": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
7
docs/swagger/parameters/Authorization.json
Normal file
7
docs/swagger/parameters/Authorization.json
Normal file
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"name": "Authorization",
|
||||
"in": "header",
|
||||
"description": "The authorization token with Bearer type",
|
||||
"required": true,
|
||||
"type": "string"
|
||||
}
|
50
docs/swagger/paths/v1_authenticate.json
Normal file
50
docs/swagger/paths/v1_authenticate.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
146
docs/swagger/paths/v1_short-codes.json
Normal file
146
docs/swagger/paths/v1_short-codes.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
53
docs/swagger/paths/v1_short-codes_{shortCode}.json
Normal file
53
docs/swagger/paths/v1_short-codes_{shortCode}.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
66
docs/swagger/paths/v1_short-codes_{shortCode}_tags.json
Normal file
66
docs/swagger/paths/v1_short-codes_{shortCode}_tags.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
55
docs/swagger/paths/v1_short-codes_{shortCode}_visits.json
Normal file
55
docs/swagger/paths/v1_short-codes_{shortCode}_visits.json
Normal 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
38
docs/swagger/swagger.json
Normal 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.
|
@ -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"
|
||||
|
||||
|
|
|
@ -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() ? '---' : '+++';
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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]];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 = []);
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
],
|
||||
],
|
||||
|
|
|
@ -4,6 +4,13 @@ use Shlinkio\Shlink\Rest\Middleware;
|
|||
return [
|
||||
|
||||
'middleware_pipeline' => [
|
||||
'pre-routing' => [
|
||||
'middleware' => [
|
||||
Middleware\PathVersionMiddleware::class,
|
||||
],
|
||||
'priority' => 11,
|
||||
],
|
||||
|
||||
'rest' => [
|
||||
'path' => '/rest',
|
||||
'middleware' => [
|
||||
|
|
|
@ -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'],
|
||||
],
|
||||
|
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
54
module/Rest/src/Middleware/PathVersionMiddleware.php
Normal file
54
module/Rest/src/Middleware/PathVersionMiddleware.php
Normal file
|
@ -0,0 +1,54 @@
|
|||
<?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();
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
|
@ -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([
|
||||
|
|
47
module/Rest/test/Middleware/PathVersionMiddlewareTest.php
Normal file
47
module/Rest/test/Middleware/PathVersionMiddlewareTest.php
Normal file
|
@ -0,0 +1,47 @@
|
|||
<?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());
|
||||
});
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue