Merge pull request #1512 from acelaya-forks/feature/api-v3

Feature/api v3
This commit is contained in:
Alejandro Celaya 2022-08-14 14:07:32 +02:00 committed by GitHub
commit a0517dfbeb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
57 changed files with 847 additions and 157 deletions

View file

@ -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 }}

View file

@ -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.

View file

@ -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"
},

View file

@ -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'),
],
],

View file

@ -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,

View 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"]
}
}

View 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"
}
}

View 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"
}
}

View file

@ -6,6 +6,7 @@
"schema": {
"type": "string",
"enum": [
"3",
"2",
"1"
]

View file

@ -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",

View file

@ -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"
}
}
}
}
},

View file

@ -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"
}
}
}

View file

@ -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"
}
}
}

View file

@ -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
}
}
}
}
}

View file

@ -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"]
}
}
}
}
}

View file

@ -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"
}
}
}
}
}

View file

@ -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
}
}
}
}
}

View file

@ -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"
}
}
}

View file

@ -3,7 +3,7 @@
"info": {
"title": "Shlink",
"description": "Shlink, the self-hosted URL shortener",
"version": "2.0"
"version": "3.0"
},
"externalDocs": {

View file

@ -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);
}

View file

@ -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,

View file

@ -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;
}

View file

@ -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;

View file

@ -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];

View file

@ -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];

View file

@ -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];

View file

@ -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();

View file

@ -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];

View file

@ -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)];

View file

@ -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());
}

View file

@ -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());
}

View file

@ -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());
}

View file

@ -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());

View file

@ -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());
}

View file

@ -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());
}

View file

@ -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());
}

View file

@ -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());
}

View file

@ -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,
],
],

View file

@ -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';

View file

@ -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,
};
}
}

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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);
}
}
}

View file

@ -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];

View file

@ -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
{

View file

@ -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'];
}
}

View file

@ -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'];
}
}

View file

@ -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
{

View file

@ -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'];
}
}

View file

@ -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'],
],
];
}

View file

@ -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'];
}
}

View file

@ -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());
}
}

View file

@ -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,
];
}
}