mirror of
https://github.com/shlinkio/shlink.git
synced 2024-11-23 21:27:44 +03:00
Merge pull request #1512 from acelaya-forks/feature/api-v3
Feature/api v3
This commit is contained in:
commit
a0517dfbeb
57 changed files with 847 additions and 157 deletions
16
.github/workflows/ci-mutation-tests.yml
vendored
16
.github/workflows/ci-mutation-tests.yml
vendored
|
@ -24,9 +24,21 @@ jobs:
|
|||
- uses: actions/download-artifact@v3
|
||||
with:
|
||||
path: build
|
||||
- name: Resolve infection args
|
||||
id: infection_args
|
||||
run: echo "::set-output name=args::--logger-github=false"
|
||||
# TODO Try to filter mutation tests to improve execution times. Investigate why --git-diff-lines --git-diff-base=develop does not work
|
||||
# run: |
|
||||
# BRANCH="${GITHUB_REF#refs/heads/}" |
|
||||
# if [[ $BRANCH == 'main' || $BRANCH == 'develop' ]]; then
|
||||
# echo "::set-output name=args::--logger-github=false"
|
||||
# else
|
||||
# echo "::set-output name=args::--logger-github=false --git-diff-lines --git-diff-base=develop"
|
||||
# fi;
|
||||
shell: bash
|
||||
- if: ${{ inputs.test-group == 'unit' }}
|
||||
run: composer infect:ci:unit -- --git-diff-lines --logger-github=false
|
||||
run: composer infect:ci:unit -- ${{ steps.infection_args.outputs.args }}
|
||||
env:
|
||||
INFECTION_BADGE_API_KEY: ${{ secrets.INFECTION_BADGE_API_KEY }}
|
||||
- if: ${{ inputs.test-group != 'unit' }}
|
||||
run: composer infect:ci:${{ inputs.test-group }} -- --git-diff-lines --logger-github=false
|
||||
run: composer infect:ci:${{ inputs.test-group }} -- ${{ steps.infection_args.outputs.args }}
|
||||
|
|
21
CHANGELOG.md
21
CHANGELOG.md
|
@ -6,7 +6,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
|
|||
|
||||
## [Unreleased]
|
||||
### Added
|
||||
* *Nothing*
|
||||
* [#1406](https://github.com/shlinkio/shlink/issues/1406) Added new REST API version 3.
|
||||
|
||||
When making requests to the REST API with `/rest/v3/...` and an error occurs, all error types will be different, with the next correlation:
|
||||
|
||||
* `INVALID_ARGUMENT` -> `https://shlink.io/api/error/invalid-data`
|
||||
* `INVALID_SHORT_URL_DELETION` -> `https://shlink.io/api/error/invalid-short-url-deletion`
|
||||
* `DOMAIN_NOT_FOUND` -> `https://shlink.io/api/error/domain-not-found`
|
||||
* `FORBIDDEN_OPERATION` -> `https://shlink.io/api/error/forbidden-tag-operation`
|
||||
* `INVALID_URL` -> `https://shlink.io/api/error/invalid-url`
|
||||
* `INVALID_SLUG` -> `https://shlink.io/api/error/non-unique-slug`
|
||||
* `INVALID_SHORTCODE` -> `https://shlink.io/api/error/short-url-not-found`
|
||||
* `TAG_CONFLICT` -> `https://shlink.io/api/error/tag-conflict`
|
||||
* `TAG_NOT_FOUND` -> `https://shlink.io/api/error/tag-not-found`
|
||||
* `MERCURE_NOT_CONFIGURED` -> `https://shlink.io/api/error/mercure-not-configured`
|
||||
* `INVALID_AUTHORIZATION` -> `https://shlink.io/api/error/missing-authentication`
|
||||
* `INVALID_API_KEY` -> `https://shlink.io/api/error/invalid-api-key`
|
||||
|
||||
If you make a request to the API with v2 or v1, the old error types will be returned, until Shlink 4 is released, when only the new ones will be used.
|
||||
|
||||
Non-error responses are not affected.
|
||||
|
||||
### Changed
|
||||
* [#1339](https://github.com/shlinkio/shlink/issues/1339) Added new test suite for CLI E2E tests.
|
||||
|
|
|
@ -69,7 +69,7 @@
|
|||
"phpunit/phpunit": "^9.5",
|
||||
"roave/security-advisories": "dev-master",
|
||||
"shlinkio/php-coding-standard": "~2.3.0",
|
||||
"shlinkio/shlink-test-utils": "^3.1.0",
|
||||
"shlinkio/shlink-test-utils": "^3.2",
|
||||
"symfony/var-dumper": "^6.1",
|
||||
"veewee/composer-run-parallel": "^1.1"
|
||||
},
|
||||
|
|
|
@ -6,12 +6,14 @@ use Laminas\Stratigility\Middleware\ErrorHandler;
|
|||
use Mezzio\ProblemDetails\ProblemDetailsMiddleware;
|
||||
use Shlinkio\Shlink\Common\Logger;
|
||||
|
||||
use function Shlinkio\Shlink\Core\toProblemDetailsType;
|
||||
|
||||
return [
|
||||
|
||||
'problem-details' => [
|
||||
'default_types_map' => [
|
||||
404 => 'NOT_FOUND',
|
||||
500 => 'INTERNAL_SERVER_ERROR',
|
||||
404 => toProblemDetailsType('not-found'),
|
||||
500 => toProblemDetailsType('internal-server-error'),
|
||||
],
|
||||
],
|
||||
|
||||
|
|
|
@ -45,6 +45,7 @@ return [
|
|||
'rest' => [
|
||||
'path' => '/rest',
|
||||
'middleware' => [
|
||||
Rest\Middleware\ErrorHandler\BackwardsCompatibleProblemDetailsHandler::class,
|
||||
Router\Middleware\ImplicitOptionsMiddleware::class,
|
||||
Rest\Middleware\BodyParserMiddleware::class,
|
||||
Rest\Middleware\AuthenticationMiddleware::class,
|
||||
|
|
9
docs/swagger/examples/short-url-invalid-args-v3.json
Normal file
9
docs/swagger/examples/short-url-invalid-args-v3.json
Normal file
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"value": {
|
||||
"title": "Invalid data",
|
||||
"type": "https://shlink.io/api/error/invalid-data",
|
||||
"detail": "Provided data is not valid",
|
||||
"status": 400,
|
||||
"invalidElements": ["maxVisits", "validSince"]
|
||||
}
|
||||
}
|
9
docs/swagger/examples/short-url-not-found-v3.json
Normal file
9
docs/swagger/examples/short-url-not-found-v3.json
Normal file
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"value": {
|
||||
"detail": "No URL found with short code \"abc123\"",
|
||||
"title": "Short URL not found",
|
||||
"type": "https://shlink.io/api/error/short-url-not-found",
|
||||
"status": 404,
|
||||
"shortCode": "abc123"
|
||||
}
|
||||
}
|
9
docs/swagger/examples/tag-not-found-v3.json
Normal file
9
docs/swagger/examples/tag-not-found-v3.json
Normal file
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"value": {
|
||||
"detail": "Tag with name \"foo\" could not be found",
|
||||
"title": "Tag not found",
|
||||
"type": "https://shlink.io/api/error/tag-not-found",
|
||||
"status": 404,
|
||||
"tag": "foo"
|
||||
}
|
||||
}
|
|
@ -6,6 +6,7 @@
|
|||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"3",
|
||||
"2",
|
||||
"1"
|
||||
]
|
||||
|
|
|
@ -327,11 +327,11 @@
|
|||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "A URL that could not be verified, if the error type is INVALID_URL"
|
||||
"description": "A URL that could not be verified, if the error type is https://shlink.io/api/error/invalid-url"
|
||||
},
|
||||
"customSlug": {
|
||||
"type": "string",
|
||||
"description": "Provided custom slug when the error type is INVALID_SLUG"
|
||||
"description": "Provided custom slug when the error type is https://shlink.io/api/error/non-unique-slug"
|
||||
},
|
||||
"domain": {
|
||||
"type": "string",
|
||||
|
@ -342,10 +342,31 @@
|
|||
]
|
||||
},
|
||||
"examples": {
|
||||
"Invalid arguments": {
|
||||
"$ref": "../examples/short-url-invalid-args.json"
|
||||
"Invalid arguments with API v3 and newer": {
|
||||
"$ref": "../examples/short-url-invalid-args-v3.json"
|
||||
},
|
||||
"Invalid long URL": {
|
||||
"Invalid long URL with API v3 and newer": {
|
||||
"value": {
|
||||
"title": "Invalid URL",
|
||||
"type": "https://shlink.io/api/error/invalid-url",
|
||||
"detail": "Provided URL foo is invalid. Try with a different one.",
|
||||
"status": 400,
|
||||
"url": "https://invalid-url.com"
|
||||
}
|
||||
},
|
||||
"Non-unique slug with API v3 and newer": {
|
||||
"value": {
|
||||
"title": "Invalid custom slug",
|
||||
"type": "https://shlink.io/api/error/non-unique-slug",
|
||||
"detail": "Provided slug \"my-slug\" is already in use.",
|
||||
"status": 400,
|
||||
"customSlug": "my-slug"
|
||||
}
|
||||
},
|
||||
"Invalid arguments previous to API v3": {
|
||||
"$ref": "../examples/short-url-invalid-args-v2.json"
|
||||
},
|
||||
"Invalid long URL previous to API v3": {
|
||||
"value": {
|
||||
"title": "Invalid URL",
|
||||
"type": "INVALID_URL",
|
||||
|
@ -354,7 +375,7 @@
|
|||
"url": "https://invalid-url.com"
|
||||
}
|
||||
},
|
||||
"Non-unique slug": {
|
||||
"Non-unique slug previous to API v3": {
|
||||
"value": {
|
||||
"title": "Invalid custom slug",
|
||||
"type": "INVALID_SLUG",
|
||||
|
|
|
@ -85,19 +85,39 @@
|
|||
"schema": {
|
||||
"$ref": "../definitions/Error.json"
|
||||
},
|
||||
"example": {
|
||||
"title": "Invalid URL",
|
||||
"type": "INVALID_URL",
|
||||
"detail": "Provided URL foo is invalid. Try with a different one.",
|
||||
"status": 400,
|
||||
"url": "https://invalid-url.com"
|
||||
"examples": {
|
||||
"API v3 and newer": {
|
||||
"value": {
|
||||
"title": "Invalid URL",
|
||||
"type": "https://shlink.io/api/error/invalid-url",
|
||||
"detail": "Provided URL foo is invalid. Try with a different one.",
|
||||
"status": 400,
|
||||
"url": "https://invalid-url.com"
|
||||
}
|
||||
},
|
||||
"Previous to API v3": {
|
||||
"value": {
|
||||
"title": "Invalid URL",
|
||||
"type": "INVALID_URL",
|
||||
"detail": "Provided URL foo is invalid. Try with a different one.",
|
||||
"status": 400,
|
||||
"url": "https://invalid-url.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"example": "INVALID_URL"
|
||||
"examples": {
|
||||
"API v3 and newer": {
|
||||
"value": "https://shlink.io/api/error/invalid-url"
|
||||
},
|
||||
"Previous to API v3": {
|
||||
"value": "INVALID_URL"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -83,8 +83,11 @@
|
|||
]
|
||||
},
|
||||
"examples": {
|
||||
"Not found": {
|
||||
"$ref": "../examples/short-url-not-found.json"
|
||||
"API v3 and newer": {
|
||||
"$ref": "../examples/short-url-not-found-v3.json"
|
||||
},
|
||||
"Previous to API v3": {
|
||||
"$ref": "../examples/short-url-not-found-v2.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -203,8 +206,11 @@
|
|||
]
|
||||
},
|
||||
"examples": {
|
||||
"Invalid arguments": {
|
||||
"$ref": "../examples/short-url-invalid-args.json"
|
||||
"API v3 and newer": {
|
||||
"$ref": "../examples/short-url-invalid-args-v3.json"
|
||||
},
|
||||
"Previous to API v3": {
|
||||
"$ref": "../examples/short-url-invalid-args-v2.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -236,8 +242,11 @@
|
|||
]
|
||||
},
|
||||
"examples": {
|
||||
"Not found": {
|
||||
"$ref": "../examples/short-url-not-found.json"
|
||||
"API v3 and newer": {
|
||||
"$ref": "../examples/short-url-not-found-v3.json"
|
||||
},
|
||||
"Previous to API v3": {
|
||||
"$ref": "../examples/short-url-not-found-v2.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -318,13 +327,27 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
"example": {
|
||||
"title": "Cannot delete short URL",
|
||||
"type": "INVALID_SHORT_URL_DELETION",
|
||||
"detail": "Impossible to delete short URL with short code \"abc123\", since it has more than \"15\" visits.",
|
||||
"status": 422,
|
||||
"shortCode": "abc123",
|
||||
"threshold": 15
|
||||
"examples": {
|
||||
"API v3 and newer": {
|
||||
"value": {
|
||||
"title": "Cannot delete short URL",
|
||||
"type": "https://shlink.io/api/error/invalid-short-url-deletion",
|
||||
"detail": "Impossible to delete short URL with short code \"abc123\", since it has more than \"15\" visits.",
|
||||
"status": 422,
|
||||
"shortCode": "abc123",
|
||||
"threshold": 15
|
||||
}
|
||||
},
|
||||
"Previous to API v3": {
|
||||
"value": {
|
||||
"title": "Cannot delete short URL",
|
||||
"type": "INVALID_SHORT_URL_DELETION",
|
||||
"detail": "Impossible to delete short URL with short code \"abc123\", since it has more than \"15\" visits.",
|
||||
"status": 422,
|
||||
"shortCode": "abc123",
|
||||
"threshold": 15
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -355,8 +378,11 @@
|
|||
]
|
||||
},
|
||||
"examples": {
|
||||
"Not found": {
|
||||
"$ref": "../examples/short-url-not-found.json"
|
||||
"API v3 and newer": {
|
||||
"$ref": "../examples/short-url-not-found-v3.json"
|
||||
},
|
||||
"Previous to API v3": {
|
||||
"$ref": "../examples/short-url-not-found-v2.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -151,8 +151,11 @@
|
|||
"$ref": "../definitions/Error.json"
|
||||
},
|
||||
"examples": {
|
||||
"Short URL not found": {
|
||||
"$ref": "../examples/short-url-not-found.json"
|
||||
"Short URL not found with API v3 and newer": {
|
||||
"$ref": "../examples/short-url-not-found-v3.json"
|
||||
},
|
||||
"Short URL not found previous to API v3": {
|
||||
"$ref": "../examples/short-url-not-found-v2.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -188,12 +188,25 @@
|
|||
"schema": {
|
||||
"$ref": "../definitions/Error.json"
|
||||
},
|
||||
"example": {
|
||||
"title": "Invalid data",
|
||||
"type": "INVALID_ARGUMENT",
|
||||
"detail": "Provided data is not valid",
|
||||
"status": 400,
|
||||
"invalidElements": ["oldName", "newName"]
|
||||
"examples": {
|
||||
"API v3 and newer": {
|
||||
"value": {
|
||||
"title": "Invalid data",
|
||||
"type": "https://shlink.io/api/error/invalid-data",
|
||||
"detail": "Provided data is not valid",
|
||||
"status": 400,
|
||||
"invalidElements": ["oldName", "newName"]
|
||||
}
|
||||
},
|
||||
"Previous to API v3": {
|
||||
"value": {
|
||||
"title": "Invalid data",
|
||||
"type": "INVALID_ARGUMENT",
|
||||
"detail": "Provided data is not valid",
|
||||
"status": 400,
|
||||
"invalidElements": ["oldName", "newName"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -205,11 +218,23 @@
|
|||
"schema": {
|
||||
"$ref": "../definitions/Error.json"
|
||||
},
|
||||
"example": {
|
||||
"detail": "You are not allowed to rename tags",
|
||||
"title": "Forbidden tag operation",
|
||||
"type": "FORBIDDEN_OPERATION",
|
||||
"status": 403
|
||||
"examples": {
|
||||
"API v3 and newer": {
|
||||
"value": {
|
||||
"detail": "You are not allowed to rename tags",
|
||||
"title": "Forbidden tag operation",
|
||||
"type": "https://shlink.io/api/error/forbidden-tag-operation",
|
||||
"status": 403
|
||||
}
|
||||
},
|
||||
"Previous to API v3": {
|
||||
"value": {
|
||||
"detail": "You are not allowed to rename tags",
|
||||
"title": "Forbidden tag operation",
|
||||
"type": "FORBIDDEN_OPERATION",
|
||||
"status": 403
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -222,8 +247,11 @@
|
|||
"$ref": "../definitions/Error.json"
|
||||
},
|
||||
"examples": {
|
||||
"Tag not found": {
|
||||
"$ref": "../examples/tag-not-found.json"
|
||||
"API v3 and newer": {
|
||||
"$ref": "../examples/tag-not-found-v3.json"
|
||||
},
|
||||
"Previous to API v3": {
|
||||
"$ref": "../examples/tag-not-found-v2.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -236,13 +264,27 @@
|
|||
"schema": {
|
||||
"$ref": "../definitions/Error.json"
|
||||
},
|
||||
"example": {
|
||||
"detail": "You cannot rename tag foo, because it already exists",
|
||||
"title": "Tag conflict",
|
||||
"type": "TAG_CONFLICT",
|
||||
"status": 409,
|
||||
"oldName": "bar",
|
||||
"newName": "foo"
|
||||
"examples": {
|
||||
"API v3 and newer": {
|
||||
"value": {
|
||||
"detail": "You cannot rename tag foo, because it already exists",
|
||||
"title": "Tag conflict",
|
||||
"type": "https://shlink.io/api/error/tag-conflict",
|
||||
"status": 409,
|
||||
"oldName": "bar",
|
||||
"newName": "foo"
|
||||
}
|
||||
},
|
||||
"Previous to API v3": {
|
||||
"value": {
|
||||
"detail": "You cannot rename tag foo, because it already exists",
|
||||
"title": "Tag conflict",
|
||||
"type": "TAG_CONFLICT",
|
||||
"status": 409,
|
||||
"oldName": "bar",
|
||||
"newName": "foo"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -300,11 +342,23 @@
|
|||
"schema": {
|
||||
"$ref": "../definitions/Error.json"
|
||||
},
|
||||
"example": {
|
||||
"detail": "You are not allowed to delete tags",
|
||||
"title": "Forbidden tag operation",
|
||||
"type": "FORBIDDEN_OPERATION",
|
||||
"status": 403
|
||||
"examples": {
|
||||
"API v3 and newer": {
|
||||
"value": {
|
||||
"detail": "You are not allowed to delete tags",
|
||||
"title": "Forbidden tag operation",
|
||||
"type": "https://shlink.io/api/error/forbidden-tag-operation",
|
||||
"status": 403
|
||||
}
|
||||
},
|
||||
"Previous to API v3": {
|
||||
"value": {
|
||||
"detail": "You are not allowed to delete tags",
|
||||
"title": "Forbidden tag operation",
|
||||
"type": "FORBIDDEN_OPERATION",
|
||||
"status": 403
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -94,12 +94,25 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
"example": {
|
||||
"title": "Invalid data",
|
||||
"type": "INVALID_ARGUMENT",
|
||||
"detail": "Provided data is not valid",
|
||||
"status": 400,
|
||||
"invalidElements": ["domain", "invalidShortUrlRedirect"]
|
||||
"examples": {
|
||||
"API v3 and newer": {
|
||||
"value": {
|
||||
"title": "Invalid data",
|
||||
"type": "https://shlink.io/api/error/invalid-data",
|
||||
"detail": "Provided data is not valid",
|
||||
"status": 400,
|
||||
"invalidElements": ["domain", "invalidShortUrlRedirect"]
|
||||
}
|
||||
},
|
||||
"Previous to API v3": {
|
||||
"value": {
|
||||
"title": "Invalid data",
|
||||
"type": "INVALID_ARGUMENT",
|
||||
"detail": "Provided data is not valid",
|
||||
"status": 400,
|
||||
"invalidElements": ["domain", "invalidShortUrlRedirect"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -147,12 +147,25 @@
|
|||
"schema": {
|
||||
"$ref": "../definitions/Error.json"
|
||||
},
|
||||
"example": {
|
||||
"detail": "Domain with authority \"example.com\" could not be found",
|
||||
"title": "Domain not found",
|
||||
"type": "DOMAIN_NOT_FOUND",
|
||||
"status": 404,
|
||||
"authority": "example.com"
|
||||
"examples": {
|
||||
"API v3 and newer": {
|
||||
"value": {
|
||||
"detail": "Domain with authority \"example.com\" could not be found",
|
||||
"title": "Domain not found",
|
||||
"type": "https://shlink.io/api/error/domain-not-found",
|
||||
"status": 404,
|
||||
"authority": "example.com"
|
||||
}
|
||||
},
|
||||
"Previous to API v3": {
|
||||
"value": {
|
||||
"detail": "Domain with authority \"example.com\" could not be found",
|
||||
"title": "Domain not found",
|
||||
"type": "DOMAIN_NOT_FOUND",
|
||||
"status": 404,
|
||||
"authority": "example.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -39,11 +39,23 @@
|
|||
"schema": {
|
||||
"$ref": "../definitions/Error.json"
|
||||
},
|
||||
"example": {
|
||||
"title": "Mercure integration not configured",
|
||||
"type": "MERCURE_NOT_CONFIGURED",
|
||||
"detail": "This Shlink instance is not integrated with a mercure hub.",
|
||||
"status": 501
|
||||
"examples": {
|
||||
"API v3 and newer": {
|
||||
"value": {
|
||||
"title": "Mercure integration not configured",
|
||||
"type": "https://shlink.io/api/error/mercure-not-configured",
|
||||
"detail": "This Shlink instance is not integrated with a mercure hub.",
|
||||
"status": 501
|
||||
}
|
||||
},
|
||||
"Previous to API v3": {
|
||||
"value": {
|
||||
"title": "Mercure integration not configured",
|
||||
"type": "MERCURE_NOT_CONFIGURED",
|
||||
"detail": "This Shlink instance is not integrated with a mercure hub.",
|
||||
"status": 501
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -148,8 +148,12 @@
|
|||
"$ref": "../definitions/Error.json"
|
||||
},
|
||||
"examples": {
|
||||
"Tag not found": {
|
||||
"$ref": "../examples/tag-not-found.json"
|
||||
|
||||
"API v3 and newer": {
|
||||
"$ref": "../examples/tag-not-found-v3.json"
|
||||
},
|
||||
"Previous to API v3": {
|
||||
"$ref": "../examples/tag-not-found-v2.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
"info": {
|
||||
"title": "Shlink",
|
||||
"description": "Shlink, the self-hosted URL shortener",
|
||||
"version": "2.0"
|
||||
"version": "3.0"
|
||||
},
|
||||
|
||||
"externalDocs": {
|
||||
|
|
|
@ -127,3 +127,8 @@ function camelCaseToHumanFriendly(string $value): string
|
|||
|
||||
return ucfirst($filter->filter($value));
|
||||
}
|
||||
|
||||
function toProblemDetailsType(string $errorCode): string
|
||||
{
|
||||
return sprintf('https://shlink.io/api/error/%s', $errorCode);
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@ use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait;
|
|||
use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
|
||||
use function Shlinkio\Shlink\Core\toProblemDetailsType;
|
||||
use function sprintf;
|
||||
|
||||
class DeleteShortUrlException extends DomainException implements ProblemDetailsExceptionInterface
|
||||
|
@ -16,7 +17,7 @@ class DeleteShortUrlException extends DomainException implements ProblemDetailsE
|
|||
use CommonProblemDetailsExceptionTrait;
|
||||
|
||||
private const TITLE = 'Cannot delete short URL';
|
||||
private const TYPE = 'INVALID_SHORT_URL_DELETION';
|
||||
public const ERROR_CODE = 'invalid-short-url-deletion';
|
||||
|
||||
public static function fromVisitsThreshold(int $threshold, ShortUrlIdentifier $identifier): self
|
||||
{
|
||||
|
@ -32,7 +33,7 @@ class DeleteShortUrlException extends DomainException implements ProblemDetailsE
|
|||
|
||||
$e->detail = $e->getMessage();
|
||||
$e->title = self::TITLE;
|
||||
$e->type = self::TYPE;
|
||||
$e->type = toProblemDetailsType(self::ERROR_CODE);
|
||||
$e->status = StatusCodeInterface::STATUS_UNPROCESSABLE_ENTITY;
|
||||
$e->additional = [
|
||||
'shortCode' => $shortCode,
|
||||
|
|
|
@ -8,6 +8,7 @@ use Fig\Http\Message\StatusCodeInterface;
|
|||
use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait;
|
||||
use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
|
||||
|
||||
use function Shlinkio\Shlink\Core\toProblemDetailsType;
|
||||
use function sprintf;
|
||||
|
||||
class DomainNotFoundException extends DomainException implements ProblemDetailsExceptionInterface
|
||||
|
@ -15,7 +16,7 @@ class DomainNotFoundException extends DomainException implements ProblemDetailsE
|
|||
use CommonProblemDetailsExceptionTrait;
|
||||
|
||||
private const TITLE = 'Domain not found';
|
||||
private const TYPE = 'DOMAIN_NOT_FOUND';
|
||||
public const ERROR_CODE = 'domain-not-found';
|
||||
|
||||
private function __construct(string $message, array $additional)
|
||||
{
|
||||
|
@ -23,7 +24,7 @@ class DomainNotFoundException extends DomainException implements ProblemDetailsE
|
|||
|
||||
$this->detail = $message;
|
||||
$this->title = self::TITLE;
|
||||
$this->type = self::TYPE;
|
||||
$this->type = toProblemDetailsType(self::ERROR_CODE);
|
||||
$this->status = StatusCodeInterface::STATUS_NOT_FOUND;
|
||||
$this->additional = $additional;
|
||||
}
|
||||
|
|
|
@ -8,12 +8,14 @@ use Fig\Http\Message\StatusCodeInterface;
|
|||
use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait;
|
||||
use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
|
||||
|
||||
use function Shlinkio\Shlink\Core\toProblemDetailsType;
|
||||
|
||||
class ForbiddenTagOperationException extends DomainException implements ProblemDetailsExceptionInterface
|
||||
{
|
||||
use CommonProblemDetailsExceptionTrait;
|
||||
|
||||
private const TITLE = 'Forbidden tag operation';
|
||||
private const TYPE = 'FORBIDDEN_OPERATION';
|
||||
public const ERROR_CODE = 'forbidden-tag-operation';
|
||||
|
||||
public static function forDeletion(): self
|
||||
{
|
||||
|
@ -31,7 +33,7 @@ class ForbiddenTagOperationException extends DomainException implements ProblemD
|
|||
|
||||
$e->detail = $message;
|
||||
$e->title = self::TITLE;
|
||||
$e->type = self::TYPE;
|
||||
$e->type = toProblemDetailsType(self::ERROR_CODE);
|
||||
$e->status = StatusCodeInterface::STATUS_FORBIDDEN;
|
||||
|
||||
return $e;
|
||||
|
|
|
@ -9,6 +9,7 @@ use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait;
|
|||
use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
|
||||
use Throwable;
|
||||
|
||||
use function Shlinkio\Shlink\Core\toProblemDetailsType;
|
||||
use function sprintf;
|
||||
|
||||
class InvalidUrlException extends DomainException implements ProblemDetailsExceptionInterface
|
||||
|
@ -16,7 +17,7 @@ class InvalidUrlException extends DomainException implements ProblemDetailsExcep
|
|||
use CommonProblemDetailsExceptionTrait;
|
||||
|
||||
private const TITLE = 'Invalid URL';
|
||||
private const TYPE = 'INVALID_URL';
|
||||
public const ERROR_CODE = 'invalid-url';
|
||||
|
||||
public static function fromUrl(string $url, ?Throwable $previous = null): self
|
||||
{
|
||||
|
@ -25,7 +26,7 @@ class InvalidUrlException extends DomainException implements ProblemDetailsExcep
|
|||
|
||||
$e->detail = $e->getMessage();
|
||||
$e->title = self::TITLE;
|
||||
$e->type = self::TYPE;
|
||||
$e->type = toProblemDetailsType(self::ERROR_CODE);
|
||||
$e->status = $status;
|
||||
$e->additional = ['url' => $url];
|
||||
|
||||
|
|
|
@ -9,6 +9,7 @@ use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait;
|
|||
use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
|
||||
use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
|
||||
|
||||
use function Shlinkio\Shlink\Core\toProblemDetailsType;
|
||||
use function sprintf;
|
||||
|
||||
class NonUniqueSlugException extends InvalidArgumentException implements ProblemDetailsExceptionInterface
|
||||
|
@ -16,7 +17,7 @@ class NonUniqueSlugException extends InvalidArgumentException implements Problem
|
|||
use CommonProblemDetailsExceptionTrait;
|
||||
|
||||
private const TITLE = 'Invalid custom slug';
|
||||
private const TYPE = 'INVALID_SLUG';
|
||||
public const ERROR_CODE = 'non-unique-slug';
|
||||
|
||||
public static function fromSlug(string $slug, ?string $domain = null): self
|
||||
{
|
||||
|
@ -25,7 +26,7 @@ class NonUniqueSlugException extends InvalidArgumentException implements Problem
|
|||
|
||||
$e->detail = $e->getMessage();
|
||||
$e->title = self::TITLE;
|
||||
$e->type = self::TYPE;
|
||||
$e->type = toProblemDetailsType(self::ERROR_CODE);
|
||||
$e->status = StatusCodeInterface::STATUS_BAD_REQUEST;
|
||||
$e->additional = ['customSlug' => $slug];
|
||||
|
||||
|
|
|
@ -9,6 +9,7 @@ use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait;
|
|||
use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
|
||||
use function Shlinkio\Shlink\Core\toProblemDetailsType;
|
||||
use function sprintf;
|
||||
|
||||
class ShortUrlNotFoundException extends DomainException implements ProblemDetailsExceptionInterface
|
||||
|
@ -16,7 +17,7 @@ class ShortUrlNotFoundException extends DomainException implements ProblemDetail
|
|||
use CommonProblemDetailsExceptionTrait;
|
||||
|
||||
private const TITLE = 'Short URL not found';
|
||||
private const TYPE = 'INVALID_SHORTCODE';
|
||||
public const ERROR_CODE = 'short-url-not-found';
|
||||
|
||||
public static function fromNotFound(ShortUrlIdentifier $identifier): self
|
||||
{
|
||||
|
@ -27,7 +28,7 @@ class ShortUrlNotFoundException extends DomainException implements ProblemDetail
|
|||
|
||||
$e->detail = $e->getMessage();
|
||||
$e->title = self::TITLE;
|
||||
$e->type = self::TYPE;
|
||||
$e->type = toProblemDetailsType(self::ERROR_CODE);
|
||||
$e->status = StatusCodeInterface::STATUS_NOT_FOUND;
|
||||
$e->additional = ['shortCode' => $shortCode];
|
||||
|
||||
|
|
|
@ -9,6 +9,7 @@ use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait;
|
|||
use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagRenaming;
|
||||
|
||||
use function Shlinkio\Shlink\Core\toProblemDetailsType;
|
||||
use function sprintf;
|
||||
|
||||
class TagConflictException extends RuntimeException implements ProblemDetailsExceptionInterface
|
||||
|
@ -16,7 +17,7 @@ class TagConflictException extends RuntimeException implements ProblemDetailsExc
|
|||
use CommonProblemDetailsExceptionTrait;
|
||||
|
||||
private const TITLE = 'Tag conflict';
|
||||
private const TYPE = 'TAG_CONFLICT';
|
||||
public const ERROR_CODE = 'tag-conflict';
|
||||
|
||||
public static function forExistingTag(TagRenaming $renaming): self
|
||||
{
|
||||
|
@ -24,7 +25,7 @@ class TagConflictException extends RuntimeException implements ProblemDetailsExc
|
|||
|
||||
$e->detail = $e->getMessage();
|
||||
$e->title = self::TITLE;
|
||||
$e->type = self::TYPE;
|
||||
$e->type = toProblemDetailsType(self::ERROR_CODE);
|
||||
$e->status = StatusCodeInterface::STATUS_CONFLICT;
|
||||
$e->additional = $renaming->toArray();
|
||||
|
||||
|
|
|
@ -8,6 +8,7 @@ use Fig\Http\Message\StatusCodeInterface;
|
|||
use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait;
|
||||
use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
|
||||
|
||||
use function Shlinkio\Shlink\Core\toProblemDetailsType;
|
||||
use function sprintf;
|
||||
|
||||
class TagNotFoundException extends DomainException implements ProblemDetailsExceptionInterface
|
||||
|
@ -15,7 +16,7 @@ class TagNotFoundException extends DomainException implements ProblemDetailsExce
|
|||
use CommonProblemDetailsExceptionTrait;
|
||||
|
||||
private const TITLE = 'Tag not found';
|
||||
private const TYPE = 'TAG_NOT_FOUND';
|
||||
public const ERROR_CODE = 'tag-not-found';
|
||||
|
||||
public static function fromTag(string $tag): self
|
||||
{
|
||||
|
@ -23,7 +24,7 @@ class TagNotFoundException extends DomainException implements ProblemDetailsExce
|
|||
|
||||
$e->detail = $e->getMessage();
|
||||
$e->title = self::TITLE;
|
||||
$e->type = self::TYPE;
|
||||
$e->type = toProblemDetailsType(self::ERROR_CODE);
|
||||
$e->status = StatusCodeInterface::STATUS_NOT_FOUND;
|
||||
$e->additional = ['tag' => $tag];
|
||||
|
||||
|
|
|
@ -12,6 +12,7 @@ use Throwable;
|
|||
|
||||
use function array_keys;
|
||||
use function Shlinkio\Shlink\Core\arrayToString;
|
||||
use function Shlinkio\Shlink\Core\toProblemDetailsType;
|
||||
use function sprintf;
|
||||
|
||||
use const PHP_EOL;
|
||||
|
@ -21,7 +22,7 @@ class ValidationException extends InvalidArgumentException implements ProblemDet
|
|||
use CommonProblemDetailsExceptionTrait;
|
||||
|
||||
private const TITLE = 'Invalid data';
|
||||
private const TYPE = 'INVALID_ARGUMENT';
|
||||
public const ERROR_CODE = 'invalid-data';
|
||||
|
||||
private array $invalidElements;
|
||||
|
||||
|
@ -37,7 +38,7 @@ class ValidationException extends InvalidArgumentException implements ProblemDet
|
|||
|
||||
$e->detail = $e->getMessage();
|
||||
$e->title = self::TITLE;
|
||||
$e->type = self::TYPE;
|
||||
$e->type = toProblemDetailsType(self::ERROR_CODE);
|
||||
$e->status = StatusCodeInterface::STATUS_BAD_REQUEST;
|
||||
$e->invalidElements = $invalidData;
|
||||
$e->additional = ['invalidElements' => array_keys($invalidData)];
|
||||
|
|
|
@ -37,7 +37,7 @@ class DeleteShortUrlExceptionTest extends TestCase
|
|||
'threshold' => $threshold,
|
||||
], $e->getAdditionalData());
|
||||
self::assertEquals('Cannot delete short URL', $e->getTitle());
|
||||
self::assertEquals('INVALID_SHORT_URL_DELETION', $e->getType());
|
||||
self::assertEquals('https://shlink.io/api/error/invalid-short-url-deletion', $e->getType());
|
||||
self::assertEquals(422, $e->getStatus());
|
||||
}
|
||||
|
||||
|
|
|
@ -21,7 +21,7 @@ class DomainNotFoundExceptionTest extends TestCase
|
|||
self::assertEquals($expectedMessage, $e->getMessage());
|
||||
self::assertEquals($expectedMessage, $e->getDetail());
|
||||
self::assertEquals('Domain not found', $e->getTitle());
|
||||
self::assertEquals('DOMAIN_NOT_FOUND', $e->getType());
|
||||
self::assertEquals('https://shlink.io/api/error/domain-not-found', $e->getType());
|
||||
self::assertEquals(['id' => $id], $e->getAdditionalData());
|
||||
self::assertEquals(404, $e->getStatus());
|
||||
}
|
||||
|
@ -36,7 +36,7 @@ class DomainNotFoundExceptionTest extends TestCase
|
|||
self::assertEquals($expectedMessage, $e->getMessage());
|
||||
self::assertEquals($expectedMessage, $e->getDetail());
|
||||
self::assertEquals('Domain not found', $e->getTitle());
|
||||
self::assertEquals('DOMAIN_NOT_FOUND', $e->getType());
|
||||
self::assertEquals('https://shlink.io/api/error/domain-not-found', $e->getType());
|
||||
self::assertEquals(['authority' => $authority], $e->getAdditionalData());
|
||||
self::assertEquals(404, $e->getStatus());
|
||||
}
|
||||
|
|
|
@ -25,7 +25,7 @@ class ForbiddenTagOperationExceptionTest extends TestCase
|
|||
self::assertEquals($expectedMessage, $e->getMessage());
|
||||
self::assertEquals($expectedMessage, $e->getDetail());
|
||||
self::assertEquals('Forbidden tag operation', $e->getTitle());
|
||||
self::assertEquals('FORBIDDEN_OPERATION', $e->getType());
|
||||
self::assertEquals('https://shlink.io/api/error/forbidden-tag-operation', $e->getType());
|
||||
self::assertEquals(403, $e->getStatus());
|
||||
}
|
||||
|
||||
|
|
|
@ -27,7 +27,7 @@ class InvalidUrlExceptionTest extends TestCase
|
|||
self::assertEquals($expectedMessage, $e->getMessage());
|
||||
self::assertEquals($expectedMessage, $e->getDetail());
|
||||
self::assertEquals('Invalid URL', $e->getTitle());
|
||||
self::assertEquals('INVALID_URL', $e->getType());
|
||||
self::assertEquals('https://shlink.io/api/error/invalid-url', $e->getType());
|
||||
self::assertEquals(['url' => $url], $e->getAdditionalData());
|
||||
self::assertEquals(StatusCodeInterface::STATUS_BAD_REQUEST, $e->getCode());
|
||||
self::assertEquals(StatusCodeInterface::STATUS_BAD_REQUEST, $e->getStatus());
|
||||
|
|
|
@ -25,7 +25,7 @@ class NonUniqueSlugExceptionTest extends TestCase
|
|||
self::assertEquals($expectedMessage, $e->getMessage());
|
||||
self::assertEquals($expectedMessage, $e->getDetail());
|
||||
self::assertEquals('Invalid custom slug', $e->getTitle());
|
||||
self::assertEquals('INVALID_SLUG', $e->getType());
|
||||
self::assertEquals('https://shlink.io/api/error/non-unique-slug', $e->getType());
|
||||
self::assertEquals(400, $e->getStatus());
|
||||
self::assertEquals($expectedAdditional, $e->getAdditionalData());
|
||||
}
|
||||
|
|
|
@ -29,7 +29,7 @@ class ShortUrlNotFoundExceptionTest extends TestCase
|
|||
self::assertEquals($expectedMessage, $e->getMessage());
|
||||
self::assertEquals($expectedMessage, $e->getDetail());
|
||||
self::assertEquals('Short URL not found', $e->getTitle());
|
||||
self::assertEquals('INVALID_SHORTCODE', $e->getType());
|
||||
self::assertEquals('https://shlink.io/api/error/short-url-not-found', $e->getType());
|
||||
self::assertEquals(404, $e->getStatus());
|
||||
self::assertEquals($expectedAdditional, $e->getAdditionalData());
|
||||
}
|
||||
|
|
|
@ -23,7 +23,7 @@ class TagConflictExceptionTest extends TestCase
|
|||
self::assertEquals($expectedMessage, $e->getMessage());
|
||||
self::assertEquals($expectedMessage, $e->getDetail());
|
||||
self::assertEquals('Tag conflict', $e->getTitle());
|
||||
self::assertEquals('TAG_CONFLICT', $e->getType());
|
||||
self::assertEquals('https://shlink.io/api/error/tag-conflict', $e->getType());
|
||||
self::assertEquals(['oldName' => $oldName, 'newName' => $newName], $e->getAdditionalData());
|
||||
self::assertEquals(409, $e->getStatus());
|
||||
}
|
||||
|
|
|
@ -21,7 +21,7 @@ class TagNotFoundExceptionTest extends TestCase
|
|||
self::assertEquals($expectedMessage, $e->getMessage());
|
||||
self::assertEquals($expectedMessage, $e->getDetail());
|
||||
self::assertEquals('Tag not found', $e->getTitle());
|
||||
self::assertEquals('TAG_NOT_FOUND', $e->getType());
|
||||
self::assertEquals('https://shlink.io/api/error/tag-not-found', $e->getType());
|
||||
self::assertEquals(['tag' => $tag], $e->getAdditionalData());
|
||||
self::assertEquals(404, $e->getStatus());
|
||||
}
|
||||
|
|
|
@ -53,6 +53,7 @@ return [
|
|||
Middleware\ShortUrl\DefaultShortCodesLengthMiddleware::class => ConfigAbstractFactory::class,
|
||||
Middleware\ShortUrl\OverrideDomainMiddleware::class => ConfigAbstractFactory::class,
|
||||
Middleware\Mercure\NotConfiguredMercureErrorHandler::class => ConfigAbstractFactory::class,
|
||||
Middleware\ErrorHandler\BackwardsCompatibleProblemDetailsHandler::class => InvokableFactory::class,
|
||||
],
|
||||
],
|
||||
|
||||
|
|
|
@ -11,7 +11,7 @@ use function sprintf;
|
|||
|
||||
class ConfigProvider
|
||||
{
|
||||
private const ROUTES_PREFIX = '/rest/v{version:1|2}';
|
||||
private const ROUTES_PREFIX = '/rest/v{version:1|2|3}';
|
||||
private const UNVERSIONED_ROUTES_PREFIX = '/rest';
|
||||
public const UNVERSIONED_HEALTH_ENDPOINT_NAME = 'unversioned_health';
|
||||
|
||||
|
|
|
@ -0,0 +1,97 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Rest\Exception;
|
||||
|
||||
use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
|
||||
use Shlinkio\Shlink\Core\Exception\DeleteShortUrlException;
|
||||
use Shlinkio\Shlink\Core\Exception\DomainNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Exception\ForbiddenTagOperationException;
|
||||
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
|
||||
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
|
||||
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Exception\TagConflictException;
|
||||
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Exception\ValidationException;
|
||||
|
||||
use function explode;
|
||||
use function Functional\last;
|
||||
|
||||
/** @deprecated */
|
||||
class BackwardsCompatibleProblemDetailsException extends RuntimeException implements ProblemDetailsExceptionInterface
|
||||
{
|
||||
private function __construct(private readonly ProblemDetailsExceptionInterface $e)
|
||||
{
|
||||
parent::__construct($e->getMessage(), $e->getCode(), $e);
|
||||
}
|
||||
|
||||
public static function fromProblemDetails(ProblemDetailsExceptionInterface $e): self
|
||||
{
|
||||
return new self($e);
|
||||
}
|
||||
|
||||
public function getStatus(): int
|
||||
{
|
||||
return $this->e->getStatus();
|
||||
}
|
||||
|
||||
public function getType(): string
|
||||
{
|
||||
return $this->remapType($this->e->getType());
|
||||
}
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return $this->e->getTitle();
|
||||
}
|
||||
|
||||
public function getDetail(): string
|
||||
{
|
||||
return $this->e->getDetail();
|
||||
}
|
||||
|
||||
public function getAdditionalData(): array
|
||||
{
|
||||
return $this->e->getAdditionalData();
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return $this->remapTypeInArray($this->e->toArray());
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return $this->remapTypeInArray($this->e->jsonSerialize());
|
||||
}
|
||||
|
||||
private function remapTypeInArray(array $wrappedArray): array
|
||||
{
|
||||
if (! isset($wrappedArray['type'])) {
|
||||
return $wrappedArray;
|
||||
}
|
||||
|
||||
return [...$wrappedArray, 'type' => $this->remapType($wrappedArray['type'])];
|
||||
}
|
||||
|
||||
private function remapType(string $wrappedType): string
|
||||
{
|
||||
$lastSegment = last(explode('/', $wrappedType));
|
||||
return match ($lastSegment) {
|
||||
ValidationException::ERROR_CODE => 'INVALID_ARGUMENT',
|
||||
DeleteShortUrlException::ERROR_CODE => 'INVALID_SHORT_URL_DELETION',
|
||||
DomainNotFoundException::ERROR_CODE => 'DOMAIN_NOT_FOUND',
|
||||
ForbiddenTagOperationException::ERROR_CODE => 'FORBIDDEN_OPERATION',
|
||||
InvalidUrlException::ERROR_CODE => 'INVALID_URL',
|
||||
NonUniqueSlugException::ERROR_CODE => 'INVALID_SLUG',
|
||||
ShortUrlNotFoundException::ERROR_CODE => 'INVALID_SHORTCODE',
|
||||
TagConflictException::ERROR_CODE => 'TAG_CONFLICT',
|
||||
TagNotFoundException::ERROR_CODE => 'TAG_NOT_FOUND',
|
||||
MercureException::ERROR_CODE => 'MERCURE_NOT_CONFIGURED',
|
||||
MissingAuthenticationException::ERROR_CODE => 'INVALID_AUTHORIZATION',
|
||||
VerifyAuthenticationException::ERROR_CODE => 'INVALID_API_KEY',
|
||||
default => $wrappedType,
|
||||
};
|
||||
}
|
||||
}
|
|
@ -8,12 +8,14 @@ use Fig\Http\Message\StatusCodeInterface;
|
|||
use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait;
|
||||
use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
|
||||
|
||||
use function Shlinkio\Shlink\Core\toProblemDetailsType;
|
||||
|
||||
class MercureException extends RuntimeException implements ProblemDetailsExceptionInterface
|
||||
{
|
||||
use CommonProblemDetailsExceptionTrait;
|
||||
|
||||
private const TITLE = 'Mercure integration not configured';
|
||||
private const TYPE = 'MERCURE_NOT_CONFIGURED';
|
||||
public const ERROR_CODE = 'mercure-not-configured';
|
||||
|
||||
public static function mercureNotConfigured(): self
|
||||
{
|
||||
|
@ -21,7 +23,7 @@ class MercureException extends RuntimeException implements ProblemDetailsExcepti
|
|||
|
||||
$e->detail = $e->getMessage();
|
||||
$e->title = self::TITLE;
|
||||
$e->type = self::TYPE;
|
||||
$e->type = toProblemDetailsType(self::ERROR_CODE);
|
||||
$e->status = StatusCodeInterface::STATUS_NOT_IMPLEMENTED;
|
||||
|
||||
return $e;
|
||||
|
|
|
@ -9,6 +9,7 @@ use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait;
|
|||
use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
|
||||
|
||||
use function implode;
|
||||
use function Shlinkio\Shlink\Core\toProblemDetailsType;
|
||||
use function sprintf;
|
||||
|
||||
class MissingAuthenticationException extends RuntimeException implements ProblemDetailsExceptionInterface
|
||||
|
@ -16,7 +17,7 @@ class MissingAuthenticationException extends RuntimeException implements Problem
|
|||
use CommonProblemDetailsExceptionTrait;
|
||||
|
||||
private const TITLE = 'Invalid authorization';
|
||||
private const TYPE = 'INVALID_AUTHORIZATION';
|
||||
public const ERROR_CODE = 'missing-authentication';
|
||||
|
||||
public static function forHeaders(array $expectedHeaders): self
|
||||
{
|
||||
|
@ -43,7 +44,7 @@ class MissingAuthenticationException extends RuntimeException implements Problem
|
|||
|
||||
$e->detail = $message;
|
||||
$e->title = self::TITLE;
|
||||
$e->type = self::TYPE;
|
||||
$e->type = toProblemDetailsType(self::ERROR_CODE);
|
||||
$e->status = StatusCodeInterface::STATUS_UNAUTHORIZED;
|
||||
|
||||
return $e;
|
||||
|
|
|
@ -8,17 +8,21 @@ use Fig\Http\Message\StatusCodeInterface;
|
|||
use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait;
|
||||
use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
|
||||
|
||||
use function Shlinkio\Shlink\Core\toProblemDetailsType;
|
||||
|
||||
class VerifyAuthenticationException extends RuntimeException implements ProblemDetailsExceptionInterface
|
||||
{
|
||||
use CommonProblemDetailsExceptionTrait;
|
||||
|
||||
public const ERROR_CODE = 'invalid-api-key';
|
||||
|
||||
public static function forInvalidApiKey(): self
|
||||
{
|
||||
$e = new self('Provided API key does not exist or is invalid.');
|
||||
|
||||
$e->detail = $e->getMessage();
|
||||
$e->title = 'Invalid API key';
|
||||
$e->type = 'INVALID_API_KEY';
|
||||
$e->type = toProblemDetailsType(self::ERROR_CODE);
|
||||
$e->status = StatusCodeInterface::STATUS_UNAUTHORIZED;
|
||||
|
||||
return $e;
|
||||
|
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Rest\Middleware\ErrorHandler;
|
||||
|
||||
use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
use Shlinkio\Shlink\Rest\Exception\BackwardsCompatibleProblemDetailsException;
|
||||
|
||||
use function version_compare;
|
||||
|
||||
/** @deprecated */
|
||||
class BackwardsCompatibleProblemDetailsHandler implements MiddlewareInterface
|
||||
{
|
||||
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||
{
|
||||
try {
|
||||
return $handler->handle($request);
|
||||
} catch (ProblemDetailsExceptionInterface $e) {
|
||||
$version = $request->getAttribute('version') ?? '2';
|
||||
throw version_compare($version, '3', '>=')
|
||||
? $e
|
||||
: BackwardsCompatibleProblemDetailsException::fromProblemDetails($e);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -60,6 +60,25 @@ class CreateShortUrlTest extends ApiTestCase
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideDuplicatedSlugApiVersions
|
||||
*/
|
||||
public function expectedTypeIsReturnedForConflictingSlugBasedOnApiVersion(
|
||||
string $version,
|
||||
string $expectedType,
|
||||
): void {
|
||||
[, $payload] = $this->createShortUrl(['customSlug' => 'custom'], version: $version);
|
||||
self::assertEquals($expectedType, $payload['type']);
|
||||
}
|
||||
|
||||
public function provideDuplicatedSlugApiVersions(): iterable
|
||||
{
|
||||
yield ['1', 'INVALID_SLUG'];
|
||||
yield ['2', 'INVALID_SLUG'];
|
||||
yield ['3', 'https://shlink.io/api/error/non-unique-slug'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideTags
|
||||
|
@ -226,15 +245,15 @@ class CreateShortUrlTest extends ApiTestCase
|
|||
* @test
|
||||
* @dataProvider provideInvalidUrls
|
||||
*/
|
||||
public function failsToCreateShortUrlWithInvalidLongUrl(string $url): void
|
||||
public function failsToCreateShortUrlWithInvalidLongUrl(string $url, string $version, string $expectedType): void
|
||||
{
|
||||
$expectedDetail = sprintf('Provided URL %s is invalid. Try with a different one.', $url);
|
||||
|
||||
[$statusCode, $payload] = $this->createShortUrl(['longUrl' => $url, 'validateUrl' => true]);
|
||||
[$statusCode, $payload] = $this->createShortUrl(['longUrl' => $url, 'validateUrl' => true], version: $version);
|
||||
|
||||
self::assertEquals(self::STATUS_BAD_REQUEST, $statusCode);
|
||||
self::assertEquals(self::STATUS_BAD_REQUEST, $payload['status']);
|
||||
self::assertEquals('INVALID_URL', $payload['type']);
|
||||
self::assertEquals($expectedType, $payload['type']);
|
||||
self::assertEquals($expectedDetail, $payload['detail']);
|
||||
self::assertEquals('Invalid URL', $payload['title']);
|
||||
self::assertEquals($url, $payload['url']);
|
||||
|
@ -242,23 +261,37 @@ class CreateShortUrlTest extends ApiTestCase
|
|||
|
||||
public function provideInvalidUrls(): iterable
|
||||
{
|
||||
yield 'empty URL' => [''];
|
||||
yield 'non-reachable URL' => ['https://this-has-to-be-invalid.com'];
|
||||
yield 'empty URL' => ['', '2', 'INVALID_URL'];
|
||||
yield 'non-reachable URL' => ['https://this-has-to-be-invalid.com', '2', 'INVALID_URL'];
|
||||
yield 'API version 3' => ['', '3', 'https://shlink.io/api/error/invalid-url'];
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function failsToCreateShortUrlWithoutLongUrl(): void
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideInvalidArgumentApiVersions
|
||||
*/
|
||||
public function failsToCreateShortUrlWithoutLongUrl(string $version, string $expectedType): void
|
||||
{
|
||||
$resp = $this->callApiWithKey(self::METHOD_POST, '/short-urls', [RequestOptions::JSON => []]);
|
||||
$resp = $this->callApiWithKey(
|
||||
self::METHOD_POST,
|
||||
sprintf('/rest/v%s/short-urls', $version),
|
||||
[RequestOptions::JSON => []],
|
||||
);
|
||||
$payload = $this->getJsonResponsePayload($resp);
|
||||
|
||||
self::assertEquals(self::STATUS_BAD_REQUEST, $resp->getStatusCode());
|
||||
self::assertEquals(self::STATUS_BAD_REQUEST, $payload['status']);
|
||||
self::assertEquals('INVALID_ARGUMENT', $payload['type']);
|
||||
self::assertEquals($expectedType, $payload['type']);
|
||||
self::assertEquals('Provided data is not valid', $payload['detail']);
|
||||
self::assertEquals('Invalid data', $payload['title']);
|
||||
}
|
||||
|
||||
public function provideInvalidArgumentApiVersions(): iterable
|
||||
{
|
||||
yield ['2', 'INVALID_ARGUMENT'];
|
||||
yield ['3', 'https://shlink.io/api/error/invalid-data'];
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function defaultDomainIsDroppedIfProvided(): void
|
||||
{
|
||||
|
@ -332,12 +365,17 @@ class CreateShortUrlTest extends ApiTestCase
|
|||
/**
|
||||
* @return array{int $statusCode, array $payload}
|
||||
*/
|
||||
private function createShortUrl(array $body = [], string $apiKey = 'valid_api_key'): array
|
||||
private function createShortUrl(array $body = [], string $apiKey = 'valid_api_key', string $version = '2'): array
|
||||
{
|
||||
if (! isset($body['longUrl'])) {
|
||||
$body['longUrl'] = 'https://app.shlink.io';
|
||||
}
|
||||
$resp = $this->callApiWithKey(self::METHOD_POST, '/short-urls', [RequestOptions::JSON => $body], $apiKey);
|
||||
$resp = $this->callApiWithKey(
|
||||
self::METHOD_POST,
|
||||
sprintf('/rest/v%s/short-urls', $version),
|
||||
[RequestOptions::JSON => $body],
|
||||
$apiKey,
|
||||
);
|
||||
$payload = $this->getJsonResponsePayload($resp);
|
||||
|
||||
return [$resp->getStatusCode(), $payload];
|
||||
|
|
|
@ -7,6 +7,8 @@ namespace ShlinkioApiTest\Shlink\Rest\Action;
|
|||
use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase;
|
||||
use ShlinkioApiTest\Shlink\Rest\Utils\NotFoundUrlHelpersTrait;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
class DeleteShortUrlTest extends ApiTestCase
|
||||
{
|
||||
use NotFoundUrlHelpersTrait;
|
||||
|
@ -33,6 +35,28 @@ class DeleteShortUrlTest extends ApiTestCase
|
|||
self::assertEquals($domain, $payload['domain'] ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideApiVersions
|
||||
*/
|
||||
public function expectedTypeIsReturnedBasedOnApiVersion(string $version, string $expectedType): void
|
||||
{
|
||||
$resp = $this->callApiWithKey(
|
||||
self::METHOD_DELETE,
|
||||
sprintf('/rest/v%s/short-urls/invalid-short-code', $version),
|
||||
);
|
||||
$payload = $this->getJsonResponsePayload($resp);
|
||||
|
||||
self::assertEquals($expectedType, $payload['type']);
|
||||
}
|
||||
|
||||
public function provideApiVersions(): iterable
|
||||
{
|
||||
yield ['1', 'INVALID_SHORTCODE'];
|
||||
yield ['2', 'INVALID_SHORTCODE'];
|
||||
yield ['3', 'https://shlink.io/api/error/short-url-not-found'];
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function properShortUrlIsDeletedWhenDomainIsProvided(): void
|
||||
{
|
||||
|
|
|
@ -7,29 +7,32 @@ namespace ShlinkioApiTest\Shlink\Rest\Action;
|
|||
use GuzzleHttp\RequestOptions;
|
||||
use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
class DeleteTagsTest extends ApiTestCase
|
||||
{
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideNonAdminApiKeys
|
||||
*/
|
||||
public function anErrorIsReturnedWithNonAdminApiKeys(string $apiKey): void
|
||||
public function anErrorIsReturnedWithNonAdminApiKeys(string $apiKey, string $version, string $expectedType): void
|
||||
{
|
||||
$resp = $this->callApiWithKey(self::METHOD_DELETE, '/tags', [
|
||||
$resp = $this->callApiWithKey(self::METHOD_DELETE, sprintf('/rest/v%s/tags', $version), [
|
||||
RequestOptions::QUERY => ['tags' => ['foo']],
|
||||
], $apiKey);
|
||||
$payload = $this->getJsonResponsePayload($resp);
|
||||
|
||||
self::assertEquals(self::STATUS_FORBIDDEN, $resp->getStatusCode());
|
||||
self::assertEquals(self::STATUS_FORBIDDEN, $payload['status']);
|
||||
self::assertEquals('FORBIDDEN_OPERATION', $payload['type']);
|
||||
self::assertEquals($expectedType, $payload['type']);
|
||||
self::assertEquals('You are not allowed to delete tags', $payload['detail']);
|
||||
self::assertEquals('Forbidden tag operation', $payload['title']);
|
||||
}
|
||||
|
||||
public function provideNonAdminApiKeys(): iterable
|
||||
{
|
||||
yield 'author' => ['author_api_key'];
|
||||
yield 'domain' => ['domain_api_key'];
|
||||
yield 'author' => ['author_api_key', '2', 'FORBIDDEN_OPERATION'];
|
||||
yield 'domain' => ['domain_api_key', '2', 'FORBIDDEN_OPERATION'];
|
||||
yield 'version 3' => ['domain_api_key', '3', 'https://shlink.io/api/error/forbidden-tag-operation'];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -65,4 +65,23 @@ class DomainVisitsTest extends ApiTestCase
|
|||
yield 'domain API key with not-owned valid domain' => ['domain_api_key', 'this_domain_is_detached.com'];
|
||||
yield 'author API key with valid domain not used in URLs' => ['author_api_key', 'this_domain_is_detached.com'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideApiVersions
|
||||
*/
|
||||
public function expectedNotFoundTypeIsReturnedForApiVersion(string $version, string $expectedType): void
|
||||
{
|
||||
$resp = $this->callApiWithKey(self::METHOD_GET, sprintf('/rest/v%s/domains/invalid.com/visits', $version));
|
||||
$payload = $this->getJsonResponsePayload($resp);
|
||||
|
||||
self::assertEquals($expectedType, $payload['type']);
|
||||
}
|
||||
|
||||
public function provideApiVersions(): iterable
|
||||
{
|
||||
yield ['1', 'DOMAIN_NOT_FOUND'];
|
||||
yield ['2', 'DOMAIN_NOT_FOUND'];
|
||||
yield ['3', 'https://shlink.io/api/error/domain-not-found'];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,6 +7,8 @@ namespace ShlinkioApiTest\Shlink\Rest\Action;
|
|||
use GuzzleHttp\RequestOptions;
|
||||
use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
class UpdateTagTest extends ApiTestCase
|
||||
{
|
||||
/**
|
||||
|
@ -34,12 +36,15 @@ class UpdateTagTest extends ApiTestCase
|
|||
yield [['newName' => 'foo']];
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function tryingToRenameInvalidTagReturnsNotFound(): void
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideTagNotFoundApiVersions
|
||||
*/
|
||||
public function tryingToRenameInvalidTagReturnsNotFound(string $version, string $expectedType): void
|
||||
{
|
||||
$expectedDetail = 'Tag with name "invalid_tag" could not be found';
|
||||
|
||||
$resp = $this->callApiWithKey(self::METHOD_PUT, '/tags', [RequestOptions::JSON => [
|
||||
$resp = $this->callApiWithKey(self::METHOD_PUT, sprintf('/rest/v%s/tags', $version), [RequestOptions::JSON => [
|
||||
'oldName' => 'invalid_tag',
|
||||
'newName' => 'foo',
|
||||
]]);
|
||||
|
@ -47,17 +52,27 @@ class UpdateTagTest extends ApiTestCase
|
|||
|
||||
self::assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode());
|
||||
self::assertEquals(self::STATUS_NOT_FOUND, $payload['status']);
|
||||
self::assertEquals('TAG_NOT_FOUND', $payload['type']);
|
||||
self::assertEquals($expectedType, $payload['type']);
|
||||
self::assertEquals($expectedDetail, $payload['detail']);
|
||||
self::assertEquals('Tag not found', $payload['title']);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function errorIsThrownWhenTryingToRenameTagToAnotherTagName(): void
|
||||
public function provideTagNotFoundApiVersions(): iterable
|
||||
{
|
||||
yield 'version 1' => ['1', 'TAG_NOT_FOUND'];
|
||||
yield 'version 2' => ['2', 'TAG_NOT_FOUND'];
|
||||
yield 'version 3' => ['3', 'https://shlink.io/api/error/tag-not-found'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideTagConflictsApiVersions
|
||||
*/
|
||||
public function errorIsThrownWhenTryingToRenameTagToAnotherTagName(string $version, string $expectedType): void
|
||||
{
|
||||
$expectedDetail = 'You cannot rename tag foo to bar, because it already exists';
|
||||
|
||||
$resp = $this->callApiWithKey(self::METHOD_PUT, '/tags', [RequestOptions::JSON => [
|
||||
$resp = $this->callApiWithKey(self::METHOD_PUT, sprintf('/rest/v%s/tags', $version), [RequestOptions::JSON => [
|
||||
'oldName' => 'foo',
|
||||
'newName' => 'bar',
|
||||
]]);
|
||||
|
@ -65,11 +80,18 @@ class UpdateTagTest extends ApiTestCase
|
|||
|
||||
self::assertEquals(self::STATUS_CONFLICT, $resp->getStatusCode());
|
||||
self::assertEquals(self::STATUS_CONFLICT, $payload['status']);
|
||||
self::assertEquals('TAG_CONFLICT', $payload['type']);
|
||||
self::assertEquals($expectedType, $payload['type']);
|
||||
self::assertEquals($expectedDetail, $payload['detail']);
|
||||
self::assertEquals('Tag conflict', $payload['title']);
|
||||
}
|
||||
|
||||
public function provideTagConflictsApiVersions(): iterable
|
||||
{
|
||||
yield 'version 1' => ['1', 'TAG_CONFLICT'];
|
||||
yield 'version 2' => ['2', 'TAG_CONFLICT'];
|
||||
yield 'version 3' => ['3', 'https://shlink.io/api/error/tag-conflict'];
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function tagIsProperlyRenamedWhenRenamingToItself(): void
|
||||
{
|
||||
|
|
|
@ -6,32 +6,47 @@ namespace ShlinkioApiTest\Shlink\Rest\Middleware;
|
|||
|
||||
use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
class AuthenticationTest extends ApiTestCase
|
||||
{
|
||||
/** @test */
|
||||
public function authorizationErrorIsReturnedIfNoApiKeyIsSent(): void
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideApiVersions
|
||||
*/
|
||||
public function authorizationErrorIsReturnedIfNoApiKeyIsSent(string $version, string $expectedType): void
|
||||
{
|
||||
$expectedDetail = 'Expected one of the following authentication headers, ["X-Api-Key"], but none were provided';
|
||||
|
||||
$resp = $this->callApi(self::METHOD_GET, '/short-urls');
|
||||
$resp = $this->callApi(self::METHOD_GET, sprintf('/rest/v%s/short-urls', $version));
|
||||
$payload = $this->getJsonResponsePayload($resp);
|
||||
|
||||
self::assertEquals(self::STATUS_UNAUTHORIZED, $resp->getStatusCode());
|
||||
self::assertEquals(self::STATUS_UNAUTHORIZED, $payload['status']);
|
||||
self::assertEquals('INVALID_AUTHORIZATION', $payload['type']);
|
||||
self::assertEquals($expectedType, $payload['type']);
|
||||
self::assertEquals($expectedDetail, $payload['detail']);
|
||||
self::assertEquals('Invalid authorization', $payload['title']);
|
||||
}
|
||||
|
||||
public function provideApiVersions(): iterable
|
||||
{
|
||||
yield 'version 1' => ['1', 'INVALID_AUTHORIZATION'];
|
||||
yield 'version 2' => ['2', 'INVALID_AUTHORIZATION'];
|
||||
yield 'version 3' => ['3', 'https://shlink.io/api/error/missing-authentication'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideInvalidApiKeys
|
||||
*/
|
||||
public function apiKeyErrorIsReturnedWhenProvidedApiKeyIsInvalid(string $apiKey): void
|
||||
{
|
||||
public function apiKeyErrorIsReturnedWhenProvidedApiKeyIsInvalid(
|
||||
string $apiKey,
|
||||
string $version,
|
||||
string $expectedType,
|
||||
): void {
|
||||
$expectedDetail = 'Provided API key does not exist or is invalid.';
|
||||
|
||||
$resp = $this->callApi(self::METHOD_GET, '/short-urls', [
|
||||
$resp = $this->callApi(self::METHOD_GET, sprintf('/rest/v%s/short-urls', $version), [
|
||||
'headers' => [
|
||||
'X-Api-Key' => $apiKey,
|
||||
],
|
||||
|
@ -40,15 +55,16 @@ class AuthenticationTest extends ApiTestCase
|
|||
|
||||
self::assertEquals(self::STATUS_UNAUTHORIZED, $resp->getStatusCode());
|
||||
self::assertEquals(self::STATUS_UNAUTHORIZED, $payload['status']);
|
||||
self::assertEquals('INVALID_API_KEY', $payload['type']);
|
||||
self::assertEquals($expectedType, $payload['type']);
|
||||
self::assertEquals($expectedDetail, $payload['detail']);
|
||||
self::assertEquals('Invalid API key', $payload['title']);
|
||||
}
|
||||
|
||||
public function provideInvalidApiKeys(): iterable
|
||||
{
|
||||
yield 'key which does not exist' => ['invalid'];
|
||||
yield 'key which is expired' => ['expired_api_key'];
|
||||
yield 'key which is disabled' => ['disabled_api_key'];
|
||||
yield 'key which does not exist' => ['invalid', '2', 'INVALID_API_KEY'];
|
||||
yield 'key which is expired' => ['expired_api_key', '2', 'INVALID_API_KEY'];
|
||||
yield 'key which is disabled' => ['disabled_api_key', '2', 'INVALID_API_KEY'];
|
||||
yield 'version 3' => ['disabled_api_key', '3', 'https://shlink.io/api/error/invalid-api-key'];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -48,10 +48,10 @@ class ConfigProviderTest extends TestCase
|
|||
['path' => '/health'],
|
||||
],
|
||||
[
|
||||
['path' => '/rest/v{version:1|2}/foo'],
|
||||
['path' => '/rest/v{version:1|2}/bar'],
|
||||
['path' => '/rest/v{version:1|2}/baz/foo'],
|
||||
['path' => '/rest/v{version:1|2}/health'],
|
||||
['path' => '/rest/v{version:1|2|3}/foo'],
|
||||
['path' => '/rest/v{version:1|2|3}/bar'],
|
||||
['path' => '/rest/v{version:1|2|3}/baz/foo'],
|
||||
['path' => '/rest/v{version:1|2|3}/health'],
|
||||
['path' => '/rest/health', 'name' => ConfigProvider::UNVERSIONED_HEALTH_ENDPOINT_NAME],
|
||||
],
|
||||
];
|
||||
|
@ -62,9 +62,9 @@ class ConfigProviderTest extends TestCase
|
|||
['path' => '/baz/foo'],
|
||||
],
|
||||
[
|
||||
['path' => '/rest/v{version:1|2}/foo'],
|
||||
['path' => '/rest/v{version:1|2}/bar'],
|
||||
['path' => '/rest/v{version:1|2}/baz/foo'],
|
||||
['path' => '/rest/v{version:1|2|3}/foo'],
|
||||
['path' => '/rest/v{version:1|2|3}/bar'],
|
||||
['path' => '/rest/v{version:1|2|3}/baz/foo'],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
|
|
@ -0,0 +1,114 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Rest\Exception;
|
||||
|
||||
use Exception;
|
||||
use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Shlinkio\Shlink\Core\Exception\DeleteShortUrlException;
|
||||
use Shlinkio\Shlink\Core\Exception\DomainNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Exception\ForbiddenTagOperationException;
|
||||
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
|
||||
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
|
||||
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Exception\TagConflictException;
|
||||
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Exception\ValidationException;
|
||||
use Shlinkio\Shlink\Rest\Exception\BackwardsCompatibleProblemDetailsException;
|
||||
use Shlinkio\Shlink\Rest\Exception\MercureException;
|
||||
use Shlinkio\Shlink\Rest\Exception\MissingAuthenticationException;
|
||||
use Shlinkio\Shlink\Rest\Exception\VerifyAuthenticationException;
|
||||
|
||||
class BackwardsCompatibleProblemDetailsExceptionTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideTypes
|
||||
*/
|
||||
public function typeIsRemappedOnWrappedException(
|
||||
string $wrappedType,
|
||||
string $expectedType,
|
||||
bool $expectSameType = false,
|
||||
): void {
|
||||
$original = new class ($wrappedType) extends Exception implements ProblemDetailsExceptionInterface {
|
||||
public function __construct(private readonly string $type)
|
||||
{
|
||||
parent::__construct('');
|
||||
}
|
||||
|
||||
public function getStatus(): int
|
||||
{
|
||||
return 123;
|
||||
}
|
||||
|
||||
public function getType(): string
|
||||
{
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return 'title';
|
||||
}
|
||||
|
||||
public function getDetail(): string
|
||||
{
|
||||
return 'detail';
|
||||
}
|
||||
|
||||
public function getAdditionalData(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return ['type' => $this->type];
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return ['type' => $this->type];
|
||||
}
|
||||
};
|
||||
$e = BackwardsCompatibleProblemDetailsException::fromProblemDetails($original);
|
||||
|
||||
self::assertEquals($e->getType(), $expectedType);
|
||||
self::assertEquals($e->toArray(), ['type' => $expectedType]);
|
||||
self::assertEquals($e->jsonSerialize(), ['type' => $expectedType]);
|
||||
|
||||
self::assertEquals($original->getTitle(), $e->getTitle());
|
||||
self::assertEquals($original->getDetail(), $e->getDetail());
|
||||
self::assertEquals($original->getAdditionalData(), $e->getAdditionalData());
|
||||
|
||||
if ($expectSameType) {
|
||||
self::assertEquals($original->getType(), $e->getType());
|
||||
self::assertEquals($original->toArray(), $e->toArray());
|
||||
self::assertEquals($original->jsonSerialize(), $e->jsonSerialize());
|
||||
} else {
|
||||
self::assertNotEquals($original->getType(), $e->getType());
|
||||
self::assertNotEquals($original->toArray(), $e->toArray());
|
||||
self::assertNotEquals($original->jsonSerialize(), $e->jsonSerialize());
|
||||
}
|
||||
}
|
||||
|
||||
public function provideTypes(): iterable
|
||||
{
|
||||
yield ['foo', 'foo', true];
|
||||
yield ['bar', 'bar', true];
|
||||
yield [ValidationException::ERROR_CODE, 'INVALID_ARGUMENT'];
|
||||
yield [DeleteShortUrlException::ERROR_CODE, 'INVALID_SHORT_URL_DELETION'];
|
||||
yield [DomainNotFoundException::ERROR_CODE, 'DOMAIN_NOT_FOUND'];
|
||||
yield [ForbiddenTagOperationException::ERROR_CODE, 'FORBIDDEN_OPERATION'];
|
||||
yield [InvalidUrlException::ERROR_CODE, 'INVALID_URL'];
|
||||
yield [NonUniqueSlugException::ERROR_CODE, 'INVALID_SLUG'];
|
||||
yield [ShortUrlNotFoundException::ERROR_CODE, 'INVALID_SHORTCODE'];
|
||||
yield [TagConflictException::ERROR_CODE, 'TAG_CONFLICT'];
|
||||
yield [TagNotFoundException::ERROR_CODE, 'TAG_NOT_FOUND'];
|
||||
yield [MercureException::ERROR_CODE, 'MERCURE_NOT_CONFIGURED'];
|
||||
yield [MissingAuthenticationException::ERROR_CODE, 'INVALID_AUTHORIZATION'];
|
||||
yield [VerifyAuthenticationException::ERROR_CODE, 'INVALID_API_KEY'];
|
||||
}
|
||||
}
|
|
@ -65,7 +65,7 @@ class MissingAuthenticationExceptionTest extends TestCase
|
|||
private function assertCommonExceptionShape(MissingAuthenticationException $e): void
|
||||
{
|
||||
self::assertEquals('Invalid authorization', $e->getTitle());
|
||||
self::assertEquals('INVALID_AUTHORIZATION', $e->getType());
|
||||
self::assertEquals('https://shlink.io/api/error/missing-authentication', $e->getType());
|
||||
self::assertEquals(401, $e->getStatus());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,76 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Rest\Middleware\ErrorHandler;
|
||||
|
||||
use Laminas\Diactoros\ServerRequestFactory;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\PhpUnit\ProphecyTrait;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
use Shlinkio\Shlink\Core\Exception\ValidationException;
|
||||
use Shlinkio\Shlink\Rest\Exception\BackwardsCompatibleProblemDetailsException;
|
||||
use Shlinkio\Shlink\Rest\Middleware\ErrorHandler\BackwardsCompatibleProblemDetailsHandler;
|
||||
use Throwable;
|
||||
|
||||
class BackwardsCompatibleProblemDetailsHandlerTest extends TestCase
|
||||
{
|
||||
use ProphecyTrait;
|
||||
|
||||
private BackwardsCompatibleProblemDetailsHandler $handler;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->handler = new BackwardsCompatibleProblemDetailsHandler();
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideExceptions
|
||||
*/
|
||||
public function expectedExceptionIsThrownBasedOnTheRequestVersion(
|
||||
ServerRequestInterface $request,
|
||||
Throwable $thrownException,
|
||||
string $expectedException,
|
||||
): void {
|
||||
$handler = $this->prophesize(RequestHandlerInterface::class);
|
||||
$handle = $handler->handle($request)->willThrow($thrownException);
|
||||
|
||||
$this->expectException($expectedException);
|
||||
$handle->shouldBeCalledOnce();
|
||||
|
||||
$this->handler->process($request, $handler->reveal());
|
||||
}
|
||||
|
||||
public function provideExceptions(): iterable
|
||||
{
|
||||
$baseRequest = ServerRequestFactory::fromGlobals();
|
||||
|
||||
yield 'no version' => [
|
||||
$baseRequest,
|
||||
ValidationException::fromArray([]),
|
||||
BackwardsCompatibleProblemDetailsException::class,
|
||||
];
|
||||
yield 'version 1' => [
|
||||
$baseRequest->withAttribute('version', '1'),
|
||||
ValidationException::fromArray([]),
|
||||
BackwardsCompatibleProblemDetailsException::class,
|
||||
];
|
||||
yield 'version 2' => [
|
||||
$baseRequest->withAttribute('version', '2'),
|
||||
ValidationException::fromArray([]),
|
||||
BackwardsCompatibleProblemDetailsException::class,
|
||||
];
|
||||
yield 'version 3' => [
|
||||
$baseRequest->withAttribute('version', '3'),
|
||||
ValidationException::fromArray([]),
|
||||
ValidationException::class,
|
||||
];
|
||||
yield 'version 4' => [
|
||||
$baseRequest->withAttribute('version', '3'),
|
||||
ValidationException::fromArray([]),
|
||||
ValidationException::class,
|
||||
];
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue