Merge pull request #554 from acelaya-forks/feature/problem-details

Feature/problem details
This commit is contained in:
Alejandro Celaya 2019-11-29 19:38:08 +01:00 committed by GitHub
commit df23f20d31
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
130 changed files with 1597 additions and 1878 deletions

View file

@ -9,7 +9,7 @@ echo 'Starting server...'
vendor/bin/zend-expressive-swoole start -d
sleep 2
vendor/bin/phpunit --order-by=random -c phpunit-api.xml --testdox --colors=always
vendor/bin/phpunit --order-by=random -c phpunit-api.xml --testdox --colors=always $*
testsExitCode=$?
vendor/bin/zend-expressive-swoole stop

View file

@ -15,7 +15,6 @@
"php": "^7.2",
"ext-json": "*",
"ext-pdo": "*",
"acelaya/ze-content-based-error-handler": "^3.0",
"akrabat/ip-address-middleware": "^1.0",
"cakephp/chronos": "^1.2",
"cocur/slugify": "^3.0",
@ -34,7 +33,7 @@
"phly/phly-event-dispatcher": "^1.0",
"predis/predis": "^1.1",
"pugx/shortid-php": "^0.5",
"shlinkio/shlink-common": "^2.2.1",
"shlinkio/shlink-common": "^2.3",
"shlinkio/shlink-event-dispatcher": "^1.0",
"shlinkio/shlink-installer": "^3.1",
"shlinkio/shlink-ip-geolocation": "^1.1",
@ -53,20 +52,20 @@
"zendframework/zend-expressive-swoole": "^2.4",
"zendframework/zend-inputfilter": "^2.10",
"zendframework/zend-paginator": "^2.8",
"zendframework/zend-problem-details": "^1.0",
"zendframework/zend-servicemanager": "^3.4",
"zendframework/zend-stdlib": "^3.2"
},
"require-dev": {
"devster/ubench": "^2.0",
"eaglewu/swoole-ide-helper": "dev-master",
"filp/whoops": "^2.4",
"infection/infection": "^0.14.2",
"phpstan/phpstan": "^0.11.16",
"phpunit/phpcov": "^6.0",
"phpunit/phpunit": "^8.3",
"roave/security-advisories": "dev-master",
"shlinkio/php-coding-standard": "~2.0.0",
"shlinkio/shlink-test-utils": "^1.0",
"shlinkio/shlink-test-utils": "^1.1",
"symfony/dotenv": "^4.3",
"symfony/var-dumper": "^4.3",
"zendframework/zend-component-installer": "^2.1",

View file

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
use Shlinkio\Shlink\Common\Logger;
use Zend\ProblemDetails\ProblemDetailsMiddleware;
use Zend\Stratigility\Middleware\ErrorHandler;
return [
'backwards_compatible_problem_details' => [
'default_type_fallbacks' => [
404 => 'NOT_FOUND',
500 => 'INTERNAL_SERVER_ERROR',
],
'json_flags' => JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION,
],
'error_handler' => [
'listeners' => [Logger\ErrorLogger::class],
],
'dependencies' => [
'delegators' => [
ErrorHandler::class => [
Logger\ErrorHandlerListenerAttachingDelegator::class,
],
ProblemDetailsMiddleware::class => [
Logger\ErrorHandlerListenerAttachingDelegator::class,
],
],
],
];

View file

@ -1,29 +0,0 @@
<?php
declare(strict_types=1);
use Zend\Expressive\Container\WhoopsErrorResponseGeneratorFactory;
return [
'dependencies' => [
'invokables' => [
'Zend\Expressive\Whoops' => Whoops\Run::class,
'Zend\Expressive\WhoopsPageHandler' => Whoops\Handler\PrettyPageHandler::class,
],
],
'whoops' => [
'json_exceptions' => [
'display' => true,
'show_trace' => true,
'ajax_only' => true,
],
],
'error_handler' => [
'plugins' => [
'factories' => [
'text/html' => WhoopsErrorResponseGeneratorFactory::class,
],
],
],
];

View file

@ -5,18 +5,31 @@ declare(strict_types=1);
namespace Shlinkio\Shlink;
use Zend\Expressive;
use Zend\ProblemDetails;
use Zend\Stratigility\Middleware\ErrorHandler;
return [
'middleware_pipeline' => [
'error-handler' => [
'middleware' => [
Expressive\Helper\ContentLengthMiddleware::class,
ErrorHandler::class,
],
],
'error-handler-rest' => [
'path' => '/rest',
'middleware' => [
Rest\Middleware\CrossDomainMiddleware::class,
Rest\Middleware\BackwardsCompatibleProblemDetailsMiddleware::class,
ProblemDetails\ProblemDetailsMiddleware::class,
],
],
'pre-routing' => [
'middleware' => [
ErrorHandler::class,
Expressive\Helper\ContentLengthMiddleware::class,
Common\Middleware\CloseDbConnectionMiddleware::class,
],
'priority' => 12,
],
'pre-routing-rest' => [
'path' => '/rest',
@ -24,33 +37,40 @@ return [
Rest\Middleware\PathVersionMiddleware::class,
Rest\Middleware\ShortUrl\ShortCodePathMiddleware::class,
],
'priority' => 11,
],
'routing' => [
'middleware' => [
Expressive\Router\Middleware\RouteMiddleware::class,
],
'priority' => 10,
],
'rest' => [
'path' => '/rest',
'middleware' => [
Rest\Middleware\CrossDomainMiddleware::class,
Expressive\Router\Middleware\ImplicitOptionsMiddleware::class,
Rest\Middleware\BodyParserMiddleware::class,
Rest\Middleware\AuthenticationMiddleware::class,
],
'priority' => 5,
],
'post-routing' => [
'dispatch' => [
'middleware' => [
Expressive\Router\Middleware\DispatchMiddleware::class,
Core\Response\NotFoundHandler::class,
],
'priority' => 1,
],
'not-found-rest' => [
'path' => '/rest',
'middleware' => [
ProblemDetails\ProblemDetailsNotFoundHandler::class,
],
],
'not-found' => [
'middleware' => [
Core\ErrorHandler\NotFoundRedirectHandler::class,
Core\ErrorHandler\NotFoundTemplateHandler::class,
],
],
],
];

View file

@ -4,9 +4,9 @@ declare(strict_types=1);
namespace Shlinkio\Shlink;
use Acelaya\ExpressiveErrorHandler;
use Zend\ConfigAggregator;
use Zend\Expressive;
use Zend\ProblemDetails;
use function Shlinkio\Shlink\Common\env;
@ -16,7 +16,7 @@ return (new ConfigAggregator\ConfigAggregator([
Expressive\Router\FastRouteRouter\ConfigProvider::class,
Expressive\Plates\ConfigProvider::class,
Expressive\Swoole\ConfigProvider::class,
ExpressiveErrorHandler\ConfigProvider::class,
ProblemDetails\ConfigProvider::class,
Common\ConfigProvider::class,
IpGeolocation\ConfigProvider::class,
Core\ConfigProvider::class,

View file

@ -1,13 +1,32 @@
{
"type": "object",
"required": ["type", "title", "detail", "status"],
"properties": {
"code": {
"type": {
"type": "string",
"description": "A machine unique code"
},
"title": {
"type": "string",
"description": "A unique title"
},
"detail": {
"type": "string",
"description": "A human-friendly error description"
},
"status": {
"type": "number",
"description": "HTTP response status code"
},
"code": {
"type": "string",
"description": "**[Deprecated] Use type instead. Not returned for v2 of the REST API** A machine unique code",
"deprecated": true
},
"message": {
"type": "string",
"description": "A human-friendly error message"
"description": "**[Deprecated] Use detail instead. Not returned for v2 of the REST API** A human-friendly error message",
"deprecated": true
}
}
}

View file

@ -0,0 +1,13 @@
{
"name": "version",
"description": "The API version to be consumed",
"in": "path",
"required": true,
"schema": {
"type": "string",
"enum": [
"2",
"1"
]
}
}

View file

@ -7,6 +7,9 @@
"summary": "List short URLs",
"description": "Returns the list of short URLs.<br><br>**Important note**: Before shlink v1.13, this endpoint used to use the `/short-codes` path instead of `/short-urls`. Both of them will keep working, while the first one is considered deprecated.",
"parameters": [
{
"$ref": "../parameters/version.json"
},
{
"name": "page",
"in": "query",
@ -150,7 +153,7 @@
"500": {
"description": "Unexpected error.",
"content": {
"application/json": {
"application/problem+json": {
"schema": {
"$ref": "../definitions/Error.json"
}
@ -175,6 +178,11 @@
"Bearer": []
}
],
"parameters": [
{
"$ref": "../parameters/version.json"
}
],
"requestBody": {
"description": "Request body.",
"required": true,
@ -256,11 +264,43 @@
}
},
"400": {
"description": "The long URL was not provided or is invalid.",
"description": "Some of provided data is invalid. Check extra fields to know exactly what.",
"content": {
"application/json": {
"application/problem+json": {
"schema": {
"$ref": "../definitions/Error.json"
"type": "object",
"allOf": [
{
"$ref": "../definitions/Error.json"
},
{
"type": "object",
"properties": {
"invalidElements": {
"type": "array",
"items": {
"type": "string",
"enum": [
"validSince",
"validUntil",
"customSlug",
"maxVisits",
"findIfExists",
"domain"
]
}
},
"url": {
"type": "string",
"description": "A URL that could not be verified, if the error type is INVALID_URL"
},
"customSlug": {
"type": "string",
"description": "Provided custom slug when the error type is INVALID_SLUG"
}
}
}
]
}
}
}
@ -268,7 +308,7 @@
"500": {
"description": "Unexpected error.",
"content": {
"application/json": {
"application/problem+json": {
"schema": {
"$ref": "../definitions/Error.json"
}

View file

@ -7,6 +7,9 @@
"summary": "Create a short URL",
"description": "Creates a short URL in a single API call. Useful for third party integrations.<br><br>**Important note**: Before shlink v1.13, this endpoint used to use the `/short-codes` path instead of `/short-urls`. Both of them will keep working, while the first one is considered deprecated.",
"parameters": [
{
"$ref": "../parameters/version.json"
},
{
"name": "apiKey",
"in": "query",
@ -77,7 +80,7 @@
"400": {
"description": "The long URL was not provided or is invalid.",
"content": {
"application/json": {
"application/problem+json": {
"schema": {
"$ref": "../definitions/Error.json"
}
@ -89,9 +92,12 @@
}
},
"examples": {
"application/json": {
"error": "INVALID_URL",
"message": "Provided URL foo is invalid. Try with a different one."
"application/problem+json": {
"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": "INVALID_URL"
}
@ -99,7 +105,7 @@
"500": {
"description": "Unexpected error.",
"content": {
"application/json": {
"application/problem+json": {
"schema": {
"$ref": "../definitions/Error.json"
}
@ -111,11 +117,11 @@
}
},
"examples": {
"application/json": {
"error": "UNKNOWN_ERROR",
"application/problem+json": {
"error": "INTERNAL_SERVER_ERROR",
"message": "Unexpected error occurred"
},
"text/plain": "UNKNOWN_ERROR"
"text/plain": "INTERNAL_SERVER_ERROR"
}
}
}

View file

@ -7,6 +7,9 @@
"summary": "Parse short code",
"description": "Get the long URL behind a short URL's short code.<br><br>**Important note**: Before shlink v1.13, this endpoint used to use the `/short-codes` path instead of `/short-urls`. Both of them will keep working, while the first one is considered deprecated.",
"parameters": [
{
"$ref": "../parameters/version.json"
},
{
"name": "shortCode",
"in": "path",
@ -62,20 +65,10 @@
}
}
},
"400": {
"description": "Provided shortCode does not match the character set currently used by the app to generate short codes.",
"content": {
"application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
},
"404": {
"description": "No URL was found for provided short code.",
"content": {
"application/json": {
"application/problem+json": {
"schema": {
"$ref": "../definitions/Error.json"
}
@ -85,7 +78,7 @@
"500": {
"description": "Unexpected error.",
"content": {
"application/json": {
"application/problem+json": {
"schema": {
"$ref": "../definitions/Error.json"
}
@ -103,6 +96,9 @@
"summary": "Edit short URL",
"description": "Update certain meta arguments from an existing short URL.<br><br>**Important note**: Before shlink v1.13, this endpoint used to use the `/short-codes` path instead of `/short-urls`. Both of them will keep working, while the first one is considered deprecated.",
"parameters": [
{
"$ref": "../parameters/version.json"
},
{
"name": "shortCode",
"in": "path",
@ -153,9 +149,31 @@
"400": {
"description": "Provided meta arguments are invalid.",
"content": {
"application/json": {
"application/problem+json": {
"schema": {
"$ref": "../definitions/Error.json"
"type": "object",
"allOf": [
{
"$ref": "../definitions/Error.json"
},
{
"type": "object",
"required": ["invalidElements"],
"properties": {
"invalidElements": {
"type": "array",
"items": {
"type": "string",
"enum": [
"validSince",
"validUntil",
"maxVisits"
]
}
}
}
}
]
}
}
}
@ -163,7 +181,7 @@
"404": {
"description": "No short URL was found for provided short code.",
"content": {
"application/json": {
"application/problem+json": {
"schema": {
"$ref": "../definitions/Error.json"
}
@ -173,7 +191,7 @@
"500": {
"description": "Unexpected error.",
"content": {
"application/json": {
"application/problem+json": {
"schema": {
"$ref": "../definitions/Error.json"
}
@ -192,6 +210,9 @@
"summary": "[DEPRECATED] Edit short URL",
"description": "**[DEPRECATED]** Use [editShortUrl](#/Short_URLs/getShortUrl) instead",
"parameters": [
{
"$ref": "../parameters/version.json"
},
{
"name": "shortCode",
"in": "path",
@ -242,7 +263,7 @@
"400": {
"description": "Provided meta arguments are invalid.",
"content": {
"application/json": {
"application/problem+json": {
"schema": {
"$ref": "../definitions/Error.json"
}
@ -252,7 +273,7 @@
"404": {
"description": "No short URL was found for provided short code.",
"content": {
"application/json": {
"application/problem+json": {
"schema": {
"$ref": "../definitions/Error.json"
}
@ -262,7 +283,7 @@
"500": {
"description": "Unexpected error.",
"content": {
"application/json": {
"application/problem+json": {
"schema": {
"$ref": "../definitions/Error.json"
}
@ -280,6 +301,9 @@
"summary": "Delete short URL",
"description": "Deletes the short URL for provided short code.<br><br>**Important note**: Before shlink v1.13, this endpoint used to use the `/short-codes` path instead of `/short-urls`. Both of them will keep working, while the first one is considered deprecated.",
"parameters": [
{
"$ref": "../parameters/version.json"
},
{
"name": "shortCode",
"in": "path",
@ -302,26 +326,28 @@
"204": {
"description": "The short URL has been properly deleted."
},
"400": {
"422": {
"description": "The visits threshold in shlink does not allow this short URL to be deleted.",
"content": {
"application/json": {
"application/problem+json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
},
"examples": {
"application/json": {
"error": "INVALID_SHORTCODE_DELETION",
"message": "It is not possible to delete URL with short code \"abc123\" because it has reached more than \"15\" visits."
"application/problem+json": {
"title": "Cannot delete short URL",
"type": "INVALID_SHORTCODE_DELETION",
"detail": "It is not possible to delete URL with short code \"abc123\" because it has reached more than \"15\" visits.",
"status": 422
}
}
},
"404": {
"description": "No short URL was found for provided short code.",
"content": {
"application/json": {
"application/problem+json": {
"schema": {
"$ref": "../definitions/Error.json"
}
@ -331,7 +357,7 @@
"500": {
"description": "Unexpected error.",
"content": {
"application/json": {
"application/problem+json": {
"schema": {
"$ref": "../definitions/Error.json"
}

View file

@ -7,6 +7,9 @@
"summary": "Edit tags on short URL",
"description": "Edit the tags on URL identified by provided short code.<br><br>**Important note**: Before shlink v1.13, this endpoint used to use the `/short-codes` path instead of `/short-urls`. Both of them will keep working, while the first one is considered deprecated.",
"parameters": [
{
"$ref": "../parameters/version.json"
},
{
"name": "shortCode",
"in": "path",
@ -78,7 +81,7 @@
"400": {
"description": "The request body does not contain a \"tags\" param with array type.",
"content": {
"application/json": {
"application/problem+json": {
"schema": {
"$ref": "../definitions/Error.json"
}

View file

@ -7,6 +7,9 @@
"summary": "List visits for short URL",
"description": "Get the list of visits on the short URL behind provided short code.<br><br>**Important note**: Before shlink v1.13, this endpoint used to use the `/short-codes` path instead of `/short-urls`. Both of them will keep working, while the first one is considered deprecated.",
"parameters": [
{
"$ref": "../parameters/version.json"
},
{
"name": "shortCode",
"in": "path",
@ -132,7 +135,7 @@
"404": {
"description": "The short code does not belong to any short URL.",
"content": {
"application/json": {
"application/problem+json": {
"schema": {
"$ref": "../definitions/Error.json"
}
@ -142,7 +145,7 @@
"500": {
"description": "Unexpected error.",
"content": {
"application/json": {
"application/problem+json": {
"schema": {
"$ref": "../definitions/Error.json"
}

View file

@ -14,6 +14,11 @@
"Bearer": []
}
],
"parameters": [
{
"$ref": "../parameters/version.json"
}
],
"responses": {
"200": {
"description": "The list of tags",
@ -53,7 +58,7 @@
"500": {
"description": "Unexpected error.",
"content": {
"application/json": {
"application/problem+json": {
"schema": {
"$ref": "../definitions/Error.json"
}
@ -78,6 +83,11 @@
"Bearer": []
}
],
"parameters": [
{
"$ref": "../parameters/version.json"
}
],
"requestBody": {
"description": "Request body.",
"required": true,
@ -140,7 +150,7 @@
"500": {
"description": "Unexpected error.",
"content": {
"application/json": {
"application/problem+json": {
"schema": {
"$ref": "../definitions/Error.json"
}
@ -165,6 +175,11 @@
"Bearer": []
}
],
"parameters": [
{
"$ref": "../parameters/version.json"
}
],
"requestBody": {
"description": "Request body.",
"required": true,
@ -197,7 +212,7 @@
"400": {
"description": "You have not provided either the oldName or the newName params.",
"content": {
"application/json": {
"application/problem+json": {
"schema": {
"$ref": "../definitions/Error.json"
}
@ -207,7 +222,7 @@
"404": {
"description": "There's no tag found with the name provided in oldName param.",
"content": {
"application/json": {
"application/problem+json": {
"schema": {
"$ref": "../definitions/Error.json"
}
@ -217,7 +232,7 @@
"500": {
"description": "Unexpected error.",
"content": {
"application/json": {
"application/problem+json": {
"schema": {
"$ref": "../definitions/Error.json"
}
@ -235,6 +250,9 @@
"summary": "Delete tags",
"description": "Deletes provided list of tags",
"parameters": [
{
"$ref": "../parameters/version.json"
},
{
"name": "tags[]",
"in": "query",
@ -263,7 +281,7 @@
"500": {
"description": "Unexpected error.",
"content": {
"application/json": {
"application/problem+json": {
"schema": {
"$ref": "../definitions/Error.json"
}

View file

@ -20,16 +20,6 @@
"responses": {
"302": {
"description": "Visit properly tracked and redirected"
},
"500": {
"description": "Unexpected error.",
"content": {
"application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
}
}
}

View file

@ -29,16 +29,6 @@
}
}
}
},
"500": {
"description": "Unexpected error.",
"content": {
"application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
}
}
}

View file

@ -40,16 +40,6 @@
}
}
}
},
"500": {
"description": "Unexpected error.",
"content": {
"application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
}
}
}

View file

@ -28,16 +28,6 @@
}
}
}
},
"500": {
"description": "Unexpected error.",
"content": {
"application/json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
}
}
}

View file

@ -71,24 +71,24 @@
],
"paths": {
"/rest/v1/short-urls": {
"/rest/v{version}/short-urls": {
"$ref": "paths/v1_short-urls.json"
},
"/rest/v1/short-urls/shorten": {
"/rest/v{version}/short-urls/shorten": {
"$ref": "paths/v1_short-urls_shorten.json"
},
"/rest/v1/short-urls/{shortCode}": {
"/rest/v{version}/short-urls/{shortCode}": {
"$ref": "paths/v1_short-urls_{shortCode}.json"
},
"/rest/v1/short-urls/{shortCode}/tags": {
"/rest/v{version}/short-urls/{shortCode}/tags": {
"$ref": "paths/v1_short-urls_{shortCode}_tags.json"
},
"/rest/v1/tags": {
"/rest/v{version}/tags": {
"$ref": "paths/v1_tags.json"
},
"/rest/v1/short-urls/{shortCode}/visits": {
"/rest/v{version}/short-urls/{shortCode}/visits": {
"$ref": "paths/v1_short-urls_{shortCode}_visits.json"
},

View file

@ -4,8 +4,8 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Api;
use InvalidArgumentException;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
@ -45,7 +45,7 @@ class DisableKeyCommand extends Command
$io->success(sprintf('API key "%s" properly disabled', $apiKey));
return ExitCodes::EXIT_SUCCESS;
} catch (InvalidArgumentException $e) {
$io->error(sprintf('API key "%s" does not exist.', $apiKey));
$io->error($e->getMessage());
return ExitCodes::EXIT_FAILURE;
}
}

View file

@ -55,22 +55,17 @@ class DeleteShortUrlCommand extends Command
try {
$this->runDelete($io, $shortCode, $ignoreThreshold);
return ExitCodes::EXIT_SUCCESS;
} catch (Exception\InvalidShortCodeException $e) {
$io->error(sprintf('Provided short code "%s" could not be found.', $shortCode));
} catch (Exception\ShortUrlNotFoundException $e) {
$io->error($e->getMessage());
return ExitCodes::EXIT_FAILURE;
} catch (Exception\DeleteShortUrlException $e) {
return $this->retry($io, $shortCode, $e);
return $this->retry($io, $shortCode, $e->getMessage());
}
}
private function retry(SymfonyStyle $io, string $shortCode, Exception\DeleteShortUrlException $e): int
private function retry(SymfonyStyle $io, string $shortCode, string $warningMsg): int
{
$warningMsg = sprintf(
'It was not possible to delete the short URL with short code "%s" because it has more than %s visits.',
$shortCode,
$e->getVisitsThreshold()
);
$io->writeln('<bg=yellow>' . $warningMsg . '</>');
$io->writeln(sprintf('<bg=yellow>%s</>', $warningMsg));
$forceDelete = $io->confirm('Do you want to delete it anyway?', false);
if ($forceDelete) {

View file

@ -141,13 +141,8 @@ class GenerateShortUrlCommand extends Command
sprintf('Generated short URL: <info>%s</info>', $shortUrl->toString($this->domainConfig)),
]);
return ExitCodes::EXIT_SUCCESS;
} catch (InvalidUrlException $e) {
$io->error(sprintf('Provided URL "%s" is invalid. Try with a different one.', $longUrl));
return ExitCodes::EXIT_FAILURE;
} catch (NonUniqueSlugException $e) {
$io->error(
sprintf('Provided slug "%s" is already in use by another URL. Try with a different one.', $customSlug)
);
} catch (InvalidUrlException | NonUniqueSlugException $e) {
$io->error($e->getMessage());
return ExitCodes::EXIT_FAILURE;
}
}

View file

@ -5,8 +5,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
@ -65,11 +64,8 @@ class ResolveUrlCommand extends Command
$url = $this->urlShortener->shortCodeToUrl($shortCode, $domain);
$output->writeln(sprintf('Long URL: <info>%s</info>', $url->getLongUrl()));
return ExitCodes::EXIT_SUCCESS;
} catch (InvalidShortCodeException $e) {
$io->error(sprintf('Provided short code "%s" has an invalid format.', $shortCode));
return ExitCodes::EXIT_FAILURE;
} catch (EntityDoesNotExistException $e) {
$io->error(sprintf('Provided short code "%s" could not be found.', $shortCode));
} catch (ShortUrlNotFoundException $e) {
$io->error($e->getMessage());
return ExitCodes::EXIT_FAILURE;
}
}

View file

@ -5,7 +5,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Tag;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
@ -13,8 +13,6 @@ use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use function sprintf;
class RenameTagCommand extends Command
{
public const NAME = 'tag:rename';
@ -47,8 +45,8 @@ class RenameTagCommand extends Command
$this->tagService->renameTag($oldName, $newName);
$io->success('Tag properly renamed.');
return ExitCodes::EXIT_SUCCESS;
} catch (EntityDoesNotExistException $e) {
$io->error(sprintf('A tag with name "%s" was not found', $oldName));
} catch (TagNotFoundException $e) {
$io->error($e->getMessage());
return ExitCodes::EXIT_FAILURE;
}
}

View file

@ -12,20 +12,16 @@ class GeolocationDbUpdateFailedException extends RuntimeException implements Exc
/** @var bool */
private $olderDbExists;
public function __construct(bool $olderDbExists, string $message = '', int $code = 0, ?Throwable $previous = null)
{
$this->olderDbExists = $olderDbExists;
parent::__construct($message, $code, $previous);
}
public static function create(bool $olderDbExists, ?Throwable $prev = null): self
{
return new self(
$olderDbExists,
$e = new self(
'An error occurred while updating geolocation database, and an older version could not be found',
0,
$prev
);
$e->olderDbExists = $olderDbExists;
return $e;
}
public function olderDbExists(): bool

View file

@ -10,7 +10,6 @@ use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
use Shlinkio\Shlink\IpGeolocation\Exception\RuntimeException;
use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdaterInterface;
use Symfony\Component\Lock\Factory as Locker;
use Throwable;
class GeolocationDbUpdater implements GeolocationDbUpdaterInterface
{
@ -40,8 +39,6 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface
try {
$this->downloadIfNeeded($mustBeUpdated, $handleProgress);
} catch (Throwable $e) {
throw $e;
} finally {
$lock->release();
}

View file

@ -29,7 +29,7 @@ class DisableKeyCommandTest extends TestCase
}
/** @test */
public function providedApiKeyIsDisabled()
public function providedApiKeyIsDisabled(): void
{
$apiKey = 'abcd1234';
$this->apiKeyService->disable($apiKey)->shouldBeCalledOnce();
@ -43,17 +43,18 @@ class DisableKeyCommandTest extends TestCase
}
/** @test */
public function errorIsReturnedIfServiceThrowsException()
public function errorIsReturnedIfServiceThrowsException(): void
{
$apiKey = 'abcd1234';
$disable = $this->apiKeyService->disable($apiKey)->willThrow(InvalidArgumentException::class);
$expectedMessage = 'API key "abcd1234" does not exist.';
$disable = $this->apiKeyService->disable($apiKey)->willThrow(new InvalidArgumentException($expectedMessage));
$this->commandTester->execute([
'apiKey' => $apiKey,
]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('API key "abcd1234" does not exist.', $output);
$this->assertStringContainsString($expectedMessage, $output);
$disable->shouldHaveBeenCalledOnce();
}
}

View file

@ -58,13 +58,13 @@ class DeleteShortUrlCommandTest extends TestCase
{
$shortCode = 'abc123';
$deleteByShortCode = $this->service->deleteByShortCode($shortCode, false)->willThrow(
Exception\InvalidShortCodeException::class
Exception\ShortUrlNotFoundException::fromNotFoundShortCode($shortCode)
);
$this->commandTester->execute(['shortCode' => $shortCode]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString(sprintf('Provided short code "%s" could not be found.', $shortCode), $output);
$this->assertStringContainsString(sprintf('No URL found with short code "%s"', $shortCode), $output);
$deleteByShortCode->shouldHaveBeenCalledOnce();
}
@ -79,11 +79,11 @@ class DeleteShortUrlCommandTest extends TestCase
): void {
$shortCode = 'abc123';
$deleteByShortCode = $this->service->deleteByShortCode($shortCode, Argument::type('bool'))->will(
function (array $args) {
function (array $args) use ($shortCode) {
$ignoreThreshold = array_pop($args);
if (!$ignoreThreshold) {
throw new Exception\DeleteShortUrlException(10);
throw Exception\DeleteShortUrlException::fromVisitsThreshold(10, $shortCode);
}
}
);
@ -93,7 +93,7 @@ class DeleteShortUrlCommandTest extends TestCase
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString(sprintf(
'It was not possible to delete the short URL with short code "%s" because it has more than 10 visits.',
'Impossible to delete short URL with short code "%s" since it has more than "10" visits.',
$shortCode
), $output);
$this->assertStringContainsString($expectedMessage, $output);
@ -112,7 +112,7 @@ class DeleteShortUrlCommandTest extends TestCase
{
$shortCode = 'abc123';
$deleteByShortCode = $this->service->deleteByShortCode($shortCode, false)->willThrow(
new Exception\DeleteShortUrlException(10)
Exception\DeleteShortUrlException::fromVisitsThreshold(10, $shortCode)
);
$this->commandTester->setInputs(['no']);
@ -120,7 +120,7 @@ class DeleteShortUrlCommandTest extends TestCase
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString(sprintf(
'It was not possible to delete the short URL with short code "%s" because it has more than 10 visits.',
'Impossible to delete short URL with short code "%s" since it has more than "10" visits.',
$shortCode
), $output);
$this->assertStringContainsString('Short URL was not deleted.', $output);

View file

@ -59,21 +59,22 @@ class GenerateShortUrlCommandTest extends TestCase
/** @test */
public function exceptionWhileParsingLongUrlOutputsError(): void
{
$this->urlShortener->urlToShortCode(Argument::cetera())->willThrow(new InvalidUrlException())
$url = 'http://domain.com/invalid';
$this->urlShortener->urlToShortCode(Argument::cetera())->willThrow(InvalidUrlException::fromUrl($url))
->shouldBeCalledOnce();
$this->commandTester->execute(['longUrl' => 'http://domain.com/invalid']);
$this->commandTester->execute(['longUrl' => $url]);
$output = $this->commandTester->getDisplay();
$this->assertEquals(ExitCodes::EXIT_FAILURE, $this->commandTester->getStatusCode());
$this->assertStringContainsString('Provided URL "http://domain.com/invalid" is invalid.', $output);
$this->assertStringContainsString('Provided URL http://domain.com/invalid is invalid.', $output);
}
/** @test */
public function providingNonUniqueSlugOutputsError(): void
{
$urlToShortCode = $this->urlShortener->urlToShortCode(Argument::cetera())->willThrow(
NonUniqueSlugException::class
NonUniqueSlugException::fromSlug('my-slug')
);
$this->commandTester->execute(['longUrl' => 'http://domain.com/invalid', '--customSlug' => 'my-slug']);

View file

@ -8,12 +8,13 @@ use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\ShortUrl\ResolveUrlCommand;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Service\UrlShortener;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
use function sprintf;
use const PHP_EOL;
class ResolveUrlCommandTest extends TestCase
@ -51,23 +52,12 @@ class ResolveUrlCommandTest extends TestCase
public function incorrectShortCodeOutputsErrorMessage(): void
{
$shortCode = 'abc123';
$this->urlShortener->shortCodeToUrl($shortCode, null)->willThrow(EntityDoesNotExistException::class)
->shouldBeCalledOnce();
$this->urlShortener->shortCodeToUrl($shortCode, null)
->willThrow(ShortUrlNotFoundException::fromNotFoundShortCode($shortCode))
->shouldBeCalledOnce();
$this->commandTester->execute(['shortCode' => $shortCode]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('Provided short code "' . $shortCode . '" could not be found.', $output);
}
/** @test */
public function wrongShortCodeFormatOutputsErrorMessage(): void
{
$shortCode = 'abc123';
$this->urlShortener->shortCodeToUrl($shortCode, null)->willThrow(new InvalidShortCodeException())
->shouldBeCalledOnce();
$this->commandTester->execute(['shortCode' => $shortCode]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('Provided short code "' . $shortCode . '" has an invalid format.', $output);
$this->assertStringContainsString(sprintf('No URL found with short code "%s"', $shortCode), $output);
}
}

View file

@ -8,7 +8,7 @@ use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Tag\RenameTagCommand;
use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;
@ -34,11 +34,11 @@ class RenameTagCommandTest extends TestCase
}
/** @test */
public function errorIsPrintedIfExceptionIsThrown()
public function errorIsPrintedIfExceptionIsThrown(): void
{
$oldName = 'foo';
$newName = 'bar';
$renameTag = $this->tagService->renameTag($oldName, $newName)->willThrow(EntityDoesNotExistException::class);
$renameTag = $this->tagService->renameTag($oldName, $newName)->willThrow(TagNotFoundException::fromTag('foo'));
$this->commandTester->execute([
'oldName' => $oldName,
@ -46,12 +46,12 @@ class RenameTagCommandTest extends TestCase
]);
$output = $this->commandTester->getDisplay();
$this->assertStringContainsString('A tag with name "foo" was not found', $output);
$this->assertStringContainsString('Tag with name "foo" could not be found', $output);
$renameTag->shouldHaveBeenCalled();
}
/** @test */
public function successIsPrintedIfNoErrorOccurs()
public function successIsPrintedIfNoErrorOccurs(): void
{
$oldName = 'foo';
$newName = 'bar';

View file

@ -12,47 +12,6 @@ use Throwable;
class GeolocationDbUpdateFailedExceptionTest extends TestCase
{
/**
* @test
* @dataProvider provideOlderDbExists
*/
public function constructCreatesExceptionWithDefaultArgs(bool $olderDbExists): void
{
$e = new GeolocationDbUpdateFailedException($olderDbExists);
$this->assertEquals($olderDbExists, $e->olderDbExists());
$this->assertEquals('', $e->getMessage());
$this->assertEquals(0, $e->getCode());
$this->assertNull($e->getPrevious());
}
public function provideOlderDbExists(): iterable
{
yield 'with older DB' => [true];
yield 'without older DB' => [false];
}
/**
* @test
* @dataProvider provideConstructorArgs
*/
public function constructCreatesException(bool $olderDbExists, string $message, int $code, ?Throwable $prev): void
{
$e = new GeolocationDbUpdateFailedException($olderDbExists, $message, $code, $prev);
$this->assertEquals($olderDbExists, $e->olderDbExists());
$this->assertEquals($message, $e->getMessage());
$this->assertEquals($code, $e->getCode());
$this->assertEquals($prev, $e->getPrevious());
}
public function provideConstructorArgs(): iterable
{
yield [true, 'This is a nice error message', 99, new Exception('prev')];
yield [false, 'Another message', 0, new RuntimeException('prev')];
yield [true, 'An yet another message', -50, null];
}
/**
* @test
* @dataProvider provideCreateArgs

View file

@ -6,8 +6,8 @@ namespace Shlinkio\Shlink\Core;
use Doctrine\Common\Cache\Cache;
use Psr\EventDispatcher\EventDispatcherInterface;
use Shlinkio\Shlink\Core\ErrorHandler;
use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions;
use Shlinkio\Shlink\Core\Response\NotFoundHandler;
use Shlinkio\Shlink\PreviewGenerator\Service\PreviewGenerator;
use Zend\Expressive\Router\RouterInterface;
use Zend\Expressive\Template\TemplateRendererInterface;
@ -17,7 +17,8 @@ return [
'dependencies' => [
'factories' => [
NotFoundHandler::class => ConfigAbstractFactory::class,
ErrorHandler\NotFoundRedirectHandler::class => ConfigAbstractFactory::class,
ErrorHandler\NotFoundTemplateHandler::class => ConfigAbstractFactory::class,
Options\AppOptions::class => ConfigAbstractFactory::class,
Options\DeleteShortUrlsOptions::class => ConfigAbstractFactory::class,
@ -43,11 +44,8 @@ return [
],
ConfigAbstractFactory::class => [
NotFoundHandler::class => [
TemplateRendererInterface::class,
NotFoundRedirectOptions::class,
'config.router.base_path',
],
ErrorHandler\NotFoundRedirectHandler::class => [NotFoundRedirectOptions::class, 'config.router.base_path'],
ErrorHandler\NotFoundTemplateHandler::class => [TemplateRendererInterface::class],
Options\AppOptions::class => ['config.app_options'],
Options\DeleteShortUrlsOptions::class => ['config.delete_short_urls'],

View file

@ -11,8 +11,7 @@ use Psr\Http\Server\RequestHandlerInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Options\AppOptions;
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
@ -72,7 +71,7 @@ abstract class AbstractTrackingAction implements MiddlewareInterface
}
return $this->createSuccessResp($this->buildUrlToRedirectTo($url, $query, $disableTrackParam));
} catch (InvalidShortCodeException | EntityDoesNotExistException $e) {
} catch (ShortUrlNotFoundException $e) {
$this->logger->warning('An error occurred while tracking short code. {e}', ['e' => $e]);
return $this->createErrorResp($request, $handler);
}

View file

@ -11,8 +11,7 @@ use Psr\Http\Server\RequestHandlerInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Shlinkio\Shlink\Common\Response\ResponseUtilsTrait;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
use Shlinkio\Shlink\PreviewGenerator\Exception\PreviewGenerationException;
use Shlinkio\Shlink\PreviewGenerator\Service\PreviewGeneratorInterface;
@ -56,7 +55,7 @@ class PreviewAction implements MiddlewareInterface
$url = $this->urlShortener->shortCodeToUrl($shortCode);
$imagePath = $this->previewGenerator->generatePreview($url->getLongUrl());
return $this->generateImageResponse($imagePath);
} catch (InvalidShortCodeException | EntityDoesNotExistException | PreviewGenerationException $e) {
} catch (ShortUrlNotFoundException | PreviewGenerationException $e) {
$this->logger->warning('An error occurred while generating preview image. {e}', ['e' => $e]);
return $handler->handle($request);
}

View file

@ -12,8 +12,7 @@ use Psr\Http\Server\RequestHandlerInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Shlinkio\Shlink\Common\Response\QrCodeResponse;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
use Zend\Expressive\Router\Exception\RuntimeException;
use Zend\Expressive\Router\RouterInterface;
@ -60,7 +59,7 @@ class QrCodeAction implements MiddlewareInterface
try {
$this->urlShortener->shortCodeToUrl($shortCode, $domain);
} catch (InvalidShortCodeException | EntityDoesNotExistException $e) {
} catch (ShortUrlNotFoundException $e) {
$this->logger->warning('An error occurred while creating QR code. {e}', ['e' => $e]);
return $handler->handle($request);
}

View file

@ -2,78 +2,40 @@
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Response;
namespace Shlinkio\Shlink\Core\ErrorHandler;
use Fig\Http\Message\StatusCodeInterface;
use InvalidArgumentException;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\UriInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Shlinkio\Shlink\Core\Action\RedirectAction;
use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions;
use Zend\Diactoros\Response;
use Zend\Expressive\Router\RouteResult;
use Zend\Expressive\Template\TemplateRendererInterface;
use function array_shift;
use function explode;
use function Functional\contains;
use function rtrim;
class NotFoundHandler implements RequestHandlerInterface
class NotFoundRedirectHandler implements MiddlewareInterface
{
public const NOT_FOUND_TEMPLATE = 'ShlinkCore::error/404';
public const INVALID_SHORT_CODE_TEMPLATE = 'ShlinkCore::invalid-short-code';
/** @var TemplateRendererInterface */
private $renderer;
/** @var NotFoundRedirectOptions */
private $redirectOptions;
/** @var string */
private $shlinkBasePath;
public function __construct(
TemplateRendererInterface $renderer,
NotFoundRedirectOptions $redirectOptions,
string $shlinkBasePath
) {
$this->renderer = $renderer;
public function __construct(NotFoundRedirectOptions $redirectOptions, string $shlinkBasePath)
{
$this->redirectOptions = $redirectOptions;
$this->shlinkBasePath = $shlinkBasePath;
}
/**
* Dispatch the next available middleware and return the response.
*
* @param ServerRequestInterface $request
*
* @return ResponseInterface
* @throws InvalidArgumentException
*/
public function handle(ServerRequestInterface $request): ResponseInterface
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
/** @var RouteResult $routeResult */
$routeResult = $request->getAttribute(RouteResult::class, RouteResult::fromRouteFailure(null));
$redirectResponse = $this->createRedirectResponse($routeResult, $request->getUri());
if ($redirectResponse !== null) {
return $redirectResponse;
}
$accepts = explode(',', $request->getHeaderLine('Accept'));
$accept = array_shift($accepts);
$status = StatusCodeInterface::STATUS_NOT_FOUND;
// If the first accepted type is json, return a json response
if (contains(['application/json', 'text/json', 'application/x-json'], $accept)) {
return new Response\JsonResponse([
'error' => 'NOT_FOUND',
'message' => 'Not found',
], $status);
}
$template = $routeResult->isFailure() ? self::NOT_FOUND_TEMPLATE : self::INVALID_SHORT_CODE_TEMPLATE;
return new Response\HtmlResponse($this->renderer->render($template), $status);
return $redirectResponse ?? $handler->handle($request);
}
private function createRedirectResponse(RouteResult $routeResult, UriInterface $uri): ?ResponseInterface

View file

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\ErrorHandler;
use Fig\Http\Message\StatusCodeInterface;
use InvalidArgumentException;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Zend\Diactoros\Response;
use Zend\Expressive\Router\RouteResult;
use Zend\Expressive\Template\TemplateRendererInterface;
class NotFoundTemplateHandler implements RequestHandlerInterface
{
public const NOT_FOUND_TEMPLATE = 'ShlinkCore::error/404';
public const INVALID_SHORT_CODE_TEMPLATE = 'ShlinkCore::invalid-short-code';
/** @var TemplateRendererInterface */
private $renderer;
public function __construct(TemplateRendererInterface $renderer)
{
$this->renderer = $renderer;
}
/**
* Dispatch the next available middleware and return the response.
*
* @param ServerRequestInterface $request
*
* @return ResponseInterface
* @throws InvalidArgumentException
*/
public function handle(ServerRequestInterface $request): ResponseInterface
{
/** @var RouteResult $routeResult */
$routeResult = $request->getAttribute(RouteResult::class, RouteResult::fromRouteFailure(null));
$status = StatusCodeInterface::STATUS_NOT_FOUND;
$template = $routeResult->isFailure() ? self::NOT_FOUND_TEMPLATE : self::INVALID_SHORT_CODE_TEMPLATE;
return new Response\HtmlResponse($this->renderer->render($template), $status);
}
}

View file

@ -4,32 +4,41 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Exception;
use Throwable;
use Fig\Http\Message\StatusCodeInterface;
use Zend\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait;
use Zend\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
use function sprintf;
class DeleteShortUrlException extends RuntimeException
class DeleteShortUrlException extends DomainException implements ProblemDetailsExceptionInterface
{
/** @var int */
private $visitsThreshold;
use CommonProblemDetailsExceptionTrait;
public function __construct(int $visitsThreshold, string $message = '', int $code = 0, ?Throwable $previous = null)
{
$this->visitsThreshold = $visitsThreshold;
parent::__construct($message, $code, $previous);
}
private const TITLE = 'Cannot delete short URL';
private const TYPE = 'INVALID_SHORTCODE_DELETION'; // FIXME Should be INVALID_SHORT_URL_DELETION
public static function fromVisitsThreshold(int $threshold, string $shortCode): self
{
return new self($threshold, sprintf(
$e = new self(sprintf(
'Impossible to delete short URL with short code "%s" since it has more than "%s" visits.',
$shortCode,
$threshold
));
$e->detail = $e->getMessage();
$e->title = self::TITLE;
$e->type = self::TYPE;
$e->status = StatusCodeInterface::STATUS_UNPROCESSABLE_ENTITY;
$e->additional = [
'shortCode' => $shortCode,
'threshold' => $threshold,
];
return $e;
}
public function getVisitsThreshold(): int
{
return $this->visitsThreshold;
return $this->additional['threshold'];
}
}

View file

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Exception;
use DomainException as SplDomainException;
class DomainException extends SplDomainException implements ExceptionInterface
{
}

View file

@ -1,30 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Exception;
use function implode;
use function sprintf;
class EntityDoesNotExistException extends RuntimeException
{
public static function createFromEntityAndConditions($entityName, array $conditions)
{
return new self(sprintf(
'Entity of type %s with params [%s] does not exist',
$entityName,
static::serializeParams($conditions)
));
}
private static function serializeParams(array $params)
{
$result = [];
foreach ($params as $key => $value) {
$result[] = sprintf('"%s" => "%s"', $key, $value);
}
return implode(', ', $result);
}
}

View file

@ -1,27 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Exception;
use Throwable;
use function sprintf;
class InvalidShortCodeException extends RuntimeException
{
public static function fromCharset(string $shortCode, string $charSet, ?Throwable $previous = null): self
{
$code = $previous !== null ? $previous->getCode() : -1;
return new static(
sprintf('Provided short code "%s" does not match the char set "%s"', $shortCode, $charSet),
$code,
$previous
);
}
public static function fromNotFoundShortCode(string $shortCode): self
{
return new static(sprintf('Provided short code "%s" does not belong to a short URL', $shortCode));
}
}

View file

@ -4,15 +4,31 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Exception;
use Fig\Http\Message\StatusCodeInterface;
use Throwable;
use Zend\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait;
use Zend\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
use function sprintf;
class InvalidUrlException extends RuntimeException
class InvalidUrlException extends DomainException implements ProblemDetailsExceptionInterface
{
use CommonProblemDetailsExceptionTrait;
private const TITLE = 'Invalid URL';
private const TYPE = 'INVALID_URL';
public static function fromUrl(string $url, ?Throwable $previous = null): self
{
$code = $previous !== null ? $previous->getCode() : -1;
return new static(sprintf('Provided URL "%s" is not an existing and valid URL', $url), $code, $previous);
$status = StatusCodeInterface::STATUS_BAD_REQUEST;
$e = new self(sprintf('Provided URL %s is invalid. Try with a different one.', $url), $status, $previous);
$e->detail = $e->getMessage();
$e->title = self::TITLE;
$e->type = self::TYPE;
$e->status = $status;
$e->additional = ['url' => $url];
return $e;
}
}

View file

@ -4,17 +4,34 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Exception;
use Fig\Http\Message\StatusCodeInterface;
use Zend\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait;
use Zend\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
use function sprintf;
class NonUniqueSlugException extends InvalidArgumentException
class NonUniqueSlugException extends InvalidArgumentException implements ProblemDetailsExceptionInterface
{
public static function fromSlug(string $slug, ?string $domain): self
use CommonProblemDetailsExceptionTrait;
private const TITLE = 'Invalid custom slug';
private const TYPE = 'INVALID_SLUG';
public static function fromSlug(string $slug, ?string $domain = null): self
{
$suffix = '';
$suffix = $domain === null ? '' : sprintf(' for domain "%s"', $domain);
$e = new self(sprintf('Provided slug "%s" is already in use%s.', $slug, $suffix));
$e->detail = $e->getMessage();
$e->title = self::TITLE;
$e->type = self::TYPE;
$e->status = StatusCodeInterface::STATUS_BAD_REQUEST;
$e->additional = ['customSlug' => $slug];
if ($domain !== null) {
$suffix = sprintf(' for domain "%s"', $domain);
$e->additional['domain'] = $domain;
}
return new self(sprintf('Provided slug "%s" is not unique%s.', $slug, $suffix));
return $e;
}
}

View file

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Exception;
use Fig\Http\Message\StatusCodeInterface;
use Zend\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait;
use Zend\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
use function sprintf;
class ShortUrlNotFoundException extends DomainException implements ProblemDetailsExceptionInterface
{
use CommonProblemDetailsExceptionTrait;
private const TITLE = 'Short URL not found';
private const TYPE = 'INVALID_SHORTCODE';
public static function fromNotFoundShortCode(string $shortCode, ?string $domain = null): self
{
$suffix = $domain === null ? '' : sprintf(' for domain "%s"', $domain);
$e = new self(sprintf('No URL found with short code "%s"%s', $shortCode, $suffix));
$e->detail = $e->getMessage();
$e->title = self::TITLE;
$e->type = self::TYPE;
$e->status = StatusCodeInterface::STATUS_NOT_FOUND;
$e->additional = ['shortCode' => $shortCode];
if ($domain !== null) {
$e->additional['domain'] = $domain;
}
return $e;
}
}

View file

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Exception;
use Fig\Http\Message\StatusCodeInterface;
use Zend\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait;
use Zend\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
use function sprintf;
class TagNotFoundException extends DomainException implements ProblemDetailsExceptionInterface
{
use CommonProblemDetailsExceptionTrait;
private const TITLE = 'Tag not found';
private const TYPE = 'TAG_NOT_FOUND';
public static function fromTag(string $tag): self
{
$e = new self(sprintf('Tag with name "%s" could not be found', $tag));
$e->detail = $e->getMessage();
$e->title = self::TITLE;
$e->type = self::TYPE;
$e->status = StatusCodeInterface::STATUS_NOT_FOUND;
$e->additional = ['tag' => $tag];
return $e;
}
}

View file

@ -4,9 +4,13 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Exception;
use Fig\Http\Message\StatusCodeInterface;
use Throwable;
use Zend\InputFilter\InputFilterInterface;
use Zend\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait;
use Zend\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
use function array_keys;
use function Functional\reduce_left;
use function is_array;
use function print_r;
@ -14,44 +18,59 @@ use function sprintf;
use const PHP_EOL;
class ValidationException extends RuntimeException
class ValidationException extends InvalidArgumentException implements ProblemDetailsExceptionInterface
{
use CommonProblemDetailsExceptionTrait;
private const TITLE = 'Invalid data';
private const TYPE = 'INVALID_ARGUMENT';
/** @var array */
private $invalidElements;
public function __construct(
string $message = '',
array $invalidElements = [],
int $code = 0,
?Throwable $previous = null
) {
$this->invalidElements = $invalidElements;
parent::__construct($message, $code, $previous);
}
public static function fromInputFilter(InputFilterInterface $inputFilter, ?Throwable $prev = null): self
{
return static::fromArray($inputFilter->getMessages(), $prev);
}
private static function fromArray(array $invalidData, ?Throwable $prev = null): self
public static function fromArray(array $invalidData, ?Throwable $prev = null): self
{
return new self(
sprintf(
'Provided data is not valid. These are the messages:%s%s%s',
PHP_EOL,
self::formMessagesToString($invalidData),
PHP_EOL
),
$invalidData,
-1,
$prev
$status = StatusCodeInterface::STATUS_BAD_REQUEST;
$e = new self('Provided data is not valid', $status, $prev);
$e->detail = $e->getMessage();
$e->title = self::TITLE;
$e->type = self::TYPE;
$e->status = StatusCodeInterface::STATUS_BAD_REQUEST;
$e->invalidElements = $invalidData;
$e->additional = ['invalidElements' => array_keys($invalidData)];
return $e;
}
public function getInvalidElements(): array
{
return $this->invalidElements;
}
public function __toString(): string
{
return sprintf(
'%s %s in %s:%s%s%sStack trace:%s%s',
__CLASS__,
$this->getMessage(),
$this->getFile(),
$this->getLine(),
$this->invalidElementsToString(),
PHP_EOL,
PHP_EOL,
$this->getTraceAsString()
);
}
private static function formMessagesToString(array $messages = []): string
private function invalidElementsToString(): string
{
return reduce_left($messages, function ($messageSet, $name, $_, string $acc) {
return reduce_left($this->getInvalidElements(), function ($messageSet, string $name, $_, string $acc) {
return $acc . sprintf(
"\n '%s' => %s",
$name,
@ -59,9 +78,4 @@ class ValidationException extends RuntimeException
);
}, '');
}
public function getInvalidElements(): array
{
return $this->invalidElements;
}
}

View file

@ -25,7 +25,7 @@ class DeleteShortUrlService implements DeleteShortUrlServiceInterface
}
/**
* @throws Exception\InvalidShortCodeException
* @throws Exception\ShortUrlNotFoundException
* @throws Exception\DeleteShortUrlException
*/
public function deleteByShortCode(string $shortCode, bool $ignoreThreshold = false): void

View file

@ -9,7 +9,7 @@ use Shlinkio\Shlink\Core\Exception;
interface DeleteShortUrlServiceInterface
{
/**
* @throws Exception\InvalidShortCodeException
* @throws Exception\ShortUrlNotFoundException
* @throws Exception\DeleteShortUrlException
*/
public function deleteByShortCode(string $shortCode, bool $ignoreThreshold = false): void;

View file

@ -6,14 +6,14 @@ namespace Shlinkio\Shlink\Core\Service\ShortUrl;
use Doctrine\ORM\EntityManagerInterface;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
trait FindShortCodeTrait
{
/**
* @param string $shortCode
* @return ShortUrl
* @throws InvalidShortCodeException
* @throws ShortUrlNotFoundException
*/
private function findByShortCode(EntityManagerInterface $em, string $shortCode): ShortUrl
{
@ -22,7 +22,7 @@ trait FindShortCodeTrait
'shortCode' => $shortCode,
]);
if ($shortUrl === null) {
throw InvalidShortCodeException::fromNotFoundShortCode($shortCode);
throw ShortUrlNotFoundException::fromNotFoundShortCode($shortCode);
}
return $shortUrl;

View file

@ -6,7 +6,7 @@ namespace Shlinkio\Shlink\Core\Service;
use Doctrine\ORM;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Paginator\Adapter\ShortUrlRepositoryAdapter;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
@ -45,7 +45,7 @@ class ShortUrlService implements ShortUrlServiceInterface
/**
* @param string[] $tags
* @throws InvalidShortCodeException
* @throws ShortUrlNotFoundException
*/
public function setTagsByShortCode(string $shortCode, array $tags = []): ShortUrl
{
@ -57,7 +57,7 @@ class ShortUrlService implements ShortUrlServiceInterface
}
/**
* @throws InvalidShortCodeException
* @throws ShortUrlNotFoundException
*/
public function updateMetadataByShortCode(string $shortCode, ShortUrlMeta $shortUrlMeta): ShortUrl
{

View file

@ -5,7 +5,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Service;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Zend\Paginator\Paginator;
@ -20,12 +20,12 @@ interface ShortUrlServiceInterface
/**
* @param string[] $tags
* @throws InvalidShortCodeException
* @throws ShortUrlNotFoundException
*/
public function setTagsByShortCode(string $shortCode, array $tags = []): ShortUrl;
/**
* @throws InvalidShortCodeException
* @throws ShortUrlNotFoundException
*/
public function updateMetadataByShortCode(string $shortCode, ShortUrlMeta $shortUrlMeta): ShortUrl;
}

View file

@ -7,7 +7,7 @@ namespace Shlinkio\Shlink\Core\Service\Tag;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM;
use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
use Shlinkio\Shlink\Core\Repository\TagRepository;
use Shlinkio\Shlink\Core\Util\TagManagerTrait;
@ -35,8 +35,7 @@ class TagService implements TagServiceInterface
}
/**
* @param array $tagNames
* @return void
* @param string[] $tagNames
*/
public function deleteTags(array $tagNames): void
{
@ -60,23 +59,17 @@ class TagService implements TagServiceInterface
}
/**
* @param string $oldName
* @param string $newName
* @return Tag
* @throws EntityDoesNotExistException
* @throws ORM\OptimisticLockException
* @throws TagNotFoundException
*/
public function renameTag($oldName, $newName): Tag
public function renameTag(string $oldName, string $newName): Tag
{
$criteria = ['name' => $oldName];
/** @var Tag|null $tag */
$tag = $this->em->getRepository(Tag::class)->findOneBy($criteria);
$tag = $this->em->getRepository(Tag::class)->findOneBy(['name' => $oldName]);
if ($tag === null) {
throw EntityDoesNotExistException::createFromEntityAndConditions(Tag::class, $criteria);
throw TagNotFoundException::fromTag($oldName);
}
$tag->rename($newName);
$this->em->flush();
return $tag;

View file

@ -6,7 +6,7 @@ namespace Shlinkio\Shlink\Core\Service\Tag;
use Doctrine\Common\Collections\Collection;
use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
interface TagServiceInterface
{
@ -17,23 +17,17 @@ interface TagServiceInterface
/**
* @param string[] $tagNames
* @return void
*/
public function deleteTags(array $tagNames): void;
/**
* Provided a list of tag names, creates all that do not exist yet
*
* @param string[] $tagNames
* @return Collection|Tag[]
*/
public function createTags(array $tagNames): Collection;
/**
* @param string $oldName
* @param string $newName
* @return Tag
* @throws EntityDoesNotExistException
* @throws TagNotFoundException
*/
public function renameTag($oldName, $newName): Tag;
public function renameTag(string $oldName, string $newName): Tag;
}

View file

@ -8,10 +8,9 @@ use Doctrine\ORM\EntityManagerInterface;
use Psr\Http\Message\UriInterface;
use Shlinkio\Shlink\Core\Domain\Resolver\PersistenceDomainResolver;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
@ -130,8 +129,8 @@ class UrlShortener implements UrlShortenerInterface
}
/**
* @throws InvalidShortCodeException
* @throws EntityDoesNotExistException
* @throws ShortUrlNotFoundException
* @fixme Move this method to a different service
*/
public function shortCodeToUrl(string $shortCode, ?string $domain = null): ShortUrl
{
@ -139,10 +138,7 @@ class UrlShortener implements UrlShortenerInterface
$shortUrlRepo = $this->em->getRepository(ShortUrl::class);
$shortUrl = $shortUrlRepo->findOneByShortCode($shortCode, $domain);
if ($shortUrl === null) {
throw EntityDoesNotExistException::createFromEntityAndConditions(ShortUrl::class, [
'shortCode' => $shortCode,
'domain' => $domain,
]);
throw ShortUrlNotFoundException::fromNotFoundShortCode($shortCode, $domain);
}
return $shortUrl;

View file

@ -6,11 +6,9 @@ namespace Shlinkio\Shlink\Core\Service;
use Psr\Http\Message\UriInterface;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\Exception\RuntimeException;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
interface UrlShortenerInterface
@ -19,13 +17,11 @@ interface UrlShortenerInterface
* @param string[] $tags
* @throws NonUniqueSlugException
* @throws InvalidUrlException
* @throws RuntimeException
*/
public function urlToShortCode(UriInterface $url, array $tags, ShortUrlMeta $meta): ShortUrl;
/**
* @throws InvalidShortCodeException
* @throws EntityDoesNotExistException
* @throws ShortUrlNotFoundException
*/
public function shortCodeToUrl(string $shortCode, ?string $domain = null): ShortUrl;
}

View file

@ -9,15 +9,13 @@ use Psr\EventDispatcher\EventDispatcherInterface;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\EventDispatcher\ShortUrlVisited;
use Shlinkio\Shlink\Core\Exception\InvalidArgumentException;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Model\VisitsParams;
use Shlinkio\Shlink\Core\Paginator\Adapter\VisitsPaginatorAdapter;
use Shlinkio\Shlink\Core\Repository\VisitRepository;
use Zend\Paginator\Paginator;
use function sprintf;
class VisitsTracker implements VisitsTrackerInterface
{
/** @var ORM\EntityManagerInterface */
@ -53,14 +51,14 @@ class VisitsTracker implements VisitsTrackerInterface
* Returns the visits on certain short code
*
* @return Visit[]|Paginator
* @throws InvalidArgumentException
* @throws ShortUrlNotFoundException
*/
public function info(string $shortCode, VisitsParams $params): Paginator
{
/** @var ORM\EntityRepository $repo */
$repo = $this->em->getRepository(ShortUrl::class);
if ($repo->count(['shortCode' => $shortCode]) < 1) {
throw new InvalidArgumentException(sprintf('Short code "%s" not found', $shortCode));
throw ShortUrlNotFoundException::fromNotFoundShortCode($shortCode);
}
/** @var VisitRepository $repo */

View file

@ -5,7 +5,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Service;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Exception\InvalidArgumentException;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Model\VisitsParams;
use Zend\Paginator\Paginator;
@ -21,7 +21,7 @@ interface VisitsTrackerInterface
* Returns the visits on certain short code
*
* @return Visit[]|Paginator
* @throws InvalidArgumentException
* @throws ShortUrlNotFoundException
*/
public function info(string $shortCode, VisitsParams $params): Paginator;
}

View file

@ -11,8 +11,7 @@ use Prophecy\Prophecy\ObjectProphecy;
use Psr\Http\Server\RequestHandlerInterface;
use Shlinkio\Shlink\Core\Action\PreviewAction;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Service\UrlShortener;
use Shlinkio\Shlink\PreviewGenerator\Service\PreviewGenerator;
use Zend\Diactoros\Response;
@ -38,19 +37,6 @@ class PreviewActionTest extends TestCase
$this->action = new PreviewAction($this->previewGenerator->reveal(), $this->urlShortener->reveal());
}
/** @test */
public function invalidShortCodeFallsBackToNextMiddleware(): void
{
$shortCode = 'abc123';
$this->urlShortener->shortCodeToUrl($shortCode)->willThrow(EntityDoesNotExistException::class)
->shouldBeCalledOnce();
$delegate = $this->prophesize(RequestHandlerInterface::class);
$delegate->handle(Argument::cetera())->shouldBeCalledOnce()
->willReturn(new Response());
$this->action->process((new ServerRequest())->withAttribute('shortCode', $shortCode), $delegate->reveal());
}
/** @test */
public function correctShortCodeReturnsImageResponse(): void
{
@ -74,7 +60,7 @@ class PreviewActionTest extends TestCase
public function invalidShortCodeExceptionFallsBackToNextMiddleware(): void
{
$shortCode = 'abc123';
$this->urlShortener->shortCodeToUrl($shortCode)->willThrow(InvalidShortCodeException::class)
$this->urlShortener->shortCodeToUrl($shortCode)->willThrow(ShortUrlNotFoundException::class)
->shouldBeCalledOnce();
$delegate = $this->prophesize(RequestHandlerInterface::class);
$process = $delegate->handle(Argument::any())->willReturn(new Response());

View file

@ -11,8 +11,7 @@ use Psr\Http\Server\RequestHandlerInterface;
use Shlinkio\Shlink\Common\Response\QrCodeResponse;
use Shlinkio\Shlink\Core\Action\QrCodeAction;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Service\UrlShortener;
use Zend\Diactoros\Response;
use Zend\Diactoros\ServerRequest;
@ -39,7 +38,7 @@ class QrCodeActionTest extends TestCase
public function aNotFoundShortCodeWillDelegateIntoNextMiddleware(): void
{
$shortCode = 'abc123';
$this->urlShortener->shortCodeToUrl($shortCode, '')->willThrow(EntityDoesNotExistException::class)
$this->urlShortener->shortCodeToUrl($shortCode, '')->willThrow(ShortUrlNotFoundException::class)
->shouldBeCalledOnce();
$delegate = $this->prophesize(RequestHandlerInterface::class);
$process = $delegate->handle(Argument::any())->willReturn(new Response());
@ -53,7 +52,7 @@ class QrCodeActionTest extends TestCase
public function anInvalidShortCodeWillReturnNotFoundResponse(): void
{
$shortCode = 'abc123';
$this->urlShortener->shortCodeToUrl($shortCode, '')->willThrow(InvalidShortCodeException::class)
$this->urlShortener->shortCodeToUrl($shortCode, '')->willThrow(ShortUrlNotFoundException::class)
->shouldBeCalledOnce();
$delegate = $this->prophesize(RequestHandlerInterface::class);
$process = $delegate->handle(Argument::any())->willReturn(new Response());

View file

@ -10,7 +10,7 @@ use Prophecy\Prophecy\ObjectProphecy;
use Psr\Http\Server\RequestHandlerInterface;
use Shlinkio\Shlink\Core\Action\RedirectAction;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Options;
use Shlinkio\Shlink\Core\Service\UrlShortener;
use Shlinkio\Shlink\Core\Service\VisitsTracker;
@ -76,7 +76,7 @@ class RedirectActionTest extends TestCase
public function nextMiddlewareIsInvokedIfLongUrlIsNotFound(): void
{
$shortCode = 'abc123';
$this->urlShortener->shortCodeToUrl($shortCode, '')->willThrow(EntityDoesNotExistException::class)
$this->urlShortener->shortCodeToUrl($shortCode, '')->willThrow(ShortUrlNotFoundException::class)
->shouldBeCalledOnce();
$this->visitTracker->track(Argument::cetera())->shouldNotBeCalled();

View file

@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\ErrorHandler;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Shlinkio\Shlink\Core\Action\RedirectAction;
use Shlinkio\Shlink\Core\ErrorHandler\NotFoundRedirectHandler;
use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions;
use Zend\Diactoros\Response;
use Zend\Diactoros\ServerRequestFactory;
use Zend\Diactoros\Uri;
use Zend\Expressive\Router\Route;
use Zend\Expressive\Router\RouteResult;
class NotFoundRedirectHandlerTest extends TestCase
{
/** @var NotFoundRedirectHandler */
private $middleware;
/** @var NotFoundRedirectOptions */
private $redirectOptions;
public function setUp(): void
{
$this->redirectOptions = new NotFoundRedirectOptions();
$this->middleware = new NotFoundRedirectHandler($this->redirectOptions, '');
}
/**
* @test
* @dataProvider provideRedirects
*/
public function expectedRedirectionIsReturnedDependingOnTheCase(
ServerRequestInterface $request,
string $expectedRedirectTo
): void {
$this->redirectOptions->invalidShortUrl = 'invalidShortUrl';
$this->redirectOptions->regular404 = 'regular404';
$this->redirectOptions->baseUrl = 'baseUrl';
$next = $this->prophesize(RequestHandlerInterface::class);
$handle = $next->handle($request)->willReturn(new Response());
$resp = $this->middleware->process($request, $next->reveal());
$this->assertInstanceOf(Response\RedirectResponse::class, $resp);
$this->assertEquals($expectedRedirectTo, $resp->getHeaderLine('Location'));
$handle->shouldNotHaveBeenCalled();
}
public function provideRedirects(): iterable
{
yield 'base URL with trailing slash' => [
ServerRequestFactory::fromGlobals()->withUri(new Uri('/')),
'baseUrl',
];
yield 'base URL without trailing slash' => [
ServerRequestFactory::fromGlobals()->withUri(new Uri('')),
'baseUrl',
];
yield 'regular 404' => [
ServerRequestFactory::fromGlobals()->withUri(new Uri('/foo/bar')),
'regular404',
];
yield 'invalid short URL' => [
ServerRequestFactory::fromGlobals()
->withAttribute(
RouteResult::class,
RouteResult::fromRoute(
new Route(
'',
$this->prophesize(MiddlewareInterface::class)->reveal(),
['GET'],
RedirectAction::class
)
)
)
->withUri(new Uri('/abc123')),
'invalidShortUrl',
];
}
/** @test */
public function nextMiddlewareIsInvokedWhenNotRedirectNeedsToOccur(): void
{
$req = ServerRequestFactory::fromGlobals();
$resp = new Response();
$next = $this->prophesize(RequestHandlerInterface::class);
$handle = $next->handle($req)->willReturn($resp);
$result = $this->middleware->process($req, $next->reveal());
$this->assertSame($resp, $result);
$handle->shouldHaveBeenCalledOnce();
}
}

View file

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\ErrorHandler;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Shlinkio\Shlink\Core\ErrorHandler\NotFoundTemplateHandler;
use Zend\Diactoros\Response;
use Zend\Diactoros\ServerRequestFactory;
use Zend\Expressive\Router\Route;
use Zend\Expressive\Router\RouteResult;
use Zend\Expressive\Template\TemplateRendererInterface;
class NotFoundTemplateHandlerTest extends TestCase
{
/** @var NotFoundTemplateHandler */
private $handler;
/** @var ObjectProphecy */
private $renderer;
public function setUp(): void
{
$this->renderer = $this->prophesize(TemplateRendererInterface::class);
$this->handler = new NotFoundTemplateHandler($this->renderer->reveal());
}
/**
* @test
* @dataProvider provideTemplates
*/
public function properErrorTemplateIsRendered(ServerRequestInterface $request, string $expectedTemplate): void
{
$request = $request->withHeader('Accept', 'text/html');
$render = $this->renderer->render($expectedTemplate)->willReturn('');
$resp = $this->handler->handle($request);
$this->assertInstanceOf(Response\HtmlResponse::class, $resp);
$render->shouldHaveBeenCalledOnce();
}
public function provideTemplates(): iterable
{
$request = ServerRequestFactory::fromGlobals();
yield [$request, NotFoundTemplateHandler::NOT_FOUND_TEMPLATE];
yield [
$request->withAttribute(
RouteResult::class,
RouteResult::fromRoute(new Route('', $this->prophesize(MiddlewareInterface::class)->reveal()))
),
NotFoundTemplateHandler::INVALID_SHORT_CODE_TEMPLATE,
];
}
}

View file

@ -5,17 +5,15 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Exception;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Common\Util\StringUtilsTrait;
use Shlinkio\Shlink\Core\Exception\DeleteShortUrlException;
use function Functional\map;
use function range;
use function Shlinkio\Shlink\Core\generateRandomShortCode;
use function sprintf;
class DeleteShortUrlExceptionTest extends TestCase
{
use StringUtilsTrait;
/**
* @test
* @dataProvider provideThresholds
@ -29,29 +27,12 @@ class DeleteShortUrlExceptionTest extends TestCase
$this->assertEquals($threshold, $e->getVisitsThreshold());
$this->assertEquals($expectedMessage, $e->getMessage());
$this->assertEquals(0, $e->getCode());
$this->assertNull($e->getPrevious());
}
/**
* @test
* @dataProvider provideThresholds
*/
public function visitsThresholdIsProperlyReturned(int $threshold): void
{
$e = new DeleteShortUrlException($threshold);
$this->assertEquals($threshold, $e->getVisitsThreshold());
$this->assertEquals('', $e->getMessage());
$this->assertEquals(0, $e->getCode());
$this->assertNull($e->getPrevious());
}
public function provideThresholds(): array
{
return map(range(5, 50, 5), function (int $number) {
$shortCode = $this->generateRandomString(6);
return [$number, $shortCode, sprintf(
return [$number, $shortCode = generateRandomShortCode(6), sprintf(
'Impossible to delete short URL with short code "%s" since it has more than "%s" visits.',
$shortCode,
$number

View file

@ -1,40 +0,0 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Exception;
use Exception;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
use Throwable;
class InvalidShortCodeExceptionTest extends TestCase
{
/**
* @test
* @dataProvider providePrevious
*/
public function properlyCreatesExceptionFromCharset(?Throwable $prev): void
{
$e = InvalidShortCodeException::fromCharset('abc123', 'def456', $prev);
$this->assertEquals('Provided short code "abc123" does not match the char set "def456"', $e->getMessage());
$this->assertEquals($prev !== null ? $prev->getCode() : -1, $e->getCode());
$this->assertEquals($prev, $e->getPrevious());
}
public function providePrevious(): iterable
{
yield 'null previous' => [null];
yield 'instance previous' => [new Exception('Previous error', 10)];
}
/** @test */
public function properlyCreatesExceptionFromNotFoundShortCode(): void
{
$e = InvalidShortCodeException::fromNotFoundShortCode('abc123');
$this->assertEquals('Provided short code "abc123" does not belong to a short URL', $e->getMessage());
}
}

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Exception;
use Exception;
use Fig\Http\Message\StatusCodeInterface;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Throwable;
@ -19,8 +20,8 @@ class InvalidUrlExceptionTest extends TestCase
{
$e = InvalidUrlException::fromUrl('http://the_url.com', $prev);
$this->assertEquals('Provided URL "http://the_url.com" is not an existing and valid URL', $e->getMessage());
$this->assertEquals($prev !== null ? $prev->getCode() : -1, $e->getCode());
$this->assertEquals('Provided URL http://the_url.com is invalid. Try with a different one.', $e->getMessage());
$this->assertEquals(StatusCodeInterface::STATUS_BAD_REQUEST, $e->getCode());
$this->assertEquals($prev, $e->getPrevious());
}

View file

@ -22,12 +22,12 @@ class NonUniqueSlugExceptionTest extends TestCase
public function provideMessages(): iterable
{
yield 'without domain' => [
'Provided slug "foo" is not unique.',
'Provided slug "foo" is already in use.',
'foo',
null,
];
yield 'with domain' => [
'Provided slug "baz" is not unique for domain "bar".',
'Provided slug "baz" is already in use for domain "bar".',
'baz',
'bar',
];

View file

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Exception;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
class ShortUrlNotFoundExceptionTest extends TestCase
{
/**
* @test
* @dataProvider provideMessages
*/
public function properlyCreatesExceptionFromNotFoundShortCode(
string $expectedMessage,
string $shortCode,
?string $domain
): void {
$e = ShortUrlNotFoundException::fromNotFoundShortCode($shortCode, $domain);
$this->assertEquals($expectedMessage, $e->getMessage());
}
public function provideMessages(): iterable
{
yield 'without domain' => [
'No URL found with short code "abc123"',
'abc123',
null,
];
yield 'with domain' => [
'No URL found with short code "bar" for domain "foo"',
'bar',
'foo',
];
}
}

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Exception;
use Fig\Http\Message\StatusCodeInterface;
use LogicException;
use PHPUnit\Framework\TestCase;
use RuntimeException;
@ -11,39 +12,11 @@ use Shlinkio\Shlink\Core\Exception\ValidationException;
use Throwable;
use Zend\InputFilter\InputFilterInterface;
use function array_keys;
use function print_r;
use function random_int;
class ValidationExceptionTest extends TestCase
{
/**
* @test
* @dataProvider provideExceptionData
*/
public function createsExceptionWrappingExpectedData(
array $args,
string $expectedMessage,
array $expectedInvalidElements,
int $expectedCode,
?Throwable $expectedPrev
): void {
$e = new ValidationException(...$args);
$this->assertEquals($expectedMessage, $e->getMessage());
$this->assertEquals($expectedInvalidElements, $e->getInvalidElements());
$this->assertEquals($expectedCode, $e->getCode());
$this->assertEquals($expectedPrev, $e->getPrevious());
}
public function provideExceptionData(): iterable
{
yield 'empty args' => [[], '', [], 0, null];
yield 'with message' => [['something'], 'something', [], 0, null];
yield 'with elements' => [['something_else', [1, 2, 3]], 'something_else', [1, 2, 3], 0, null];
yield 'with code' => [['foo', [], $foo = random_int(-100, 100)], 'foo', [], $foo, null];
yield 'with prev' => [['bar', [], 8, $e = new RuntimeException()], 'bar', [], 8, $e];
}
/**
* @test
* @dataProvider provideExceptions
@ -55,12 +28,9 @@ class ValidationExceptionTest extends TestCase
'something' => ['baz', 'foo'],
];
$barValue = print_r(['baz', 'foo'], true);
$expectedMessage = <<<EOT
Provided data is not valid. These are the messages:
$expectedStringRepresentation = <<<EOT
'foo' => bar
'something' => {$barValue}
EOT;
$inputFilter = $this->prophesize(InputFilterInterface::class);
@ -69,9 +39,11 @@ EOT;
$e = ValidationException::fromInputFilter($inputFilter->reveal());
$this->assertEquals($invalidData, $e->getInvalidElements());
$this->assertEquals($expectedMessage, $e->getMessage());
$this->assertEquals(-1, $e->getCode());
$this->assertEquals(['invalidElements' => array_keys($invalidData)], $e->getAdditionalData());
$this->assertEquals('Provided data is not valid', $e->getMessage());
$this->assertEquals(StatusCodeInterface::STATUS_BAD_REQUEST, $e->getCode());
$this->assertEquals($prev, $e->getPrevious());
$this->assertStringContainsString($expectedStringRepresentation, (string) $e);
$getMessages->shouldHaveBeenCalledOnce();
}

View file

@ -1,142 +0,0 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Response;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Shlinkio\Shlink\Core\Action\RedirectAction;
use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions;
use Shlinkio\Shlink\Core\Response\NotFoundHandler;
use Zend\Diactoros\Response;
use Zend\Diactoros\ServerRequest;
use Zend\Diactoros\ServerRequestFactory;
use Zend\Diactoros\Uri;
use Zend\Expressive\Router\Route;
use Zend\Expressive\Router\RouteResult;
use Zend\Expressive\Template\TemplateRendererInterface;
class NotFoundHandlerTest extends TestCase
{
/** @var NotFoundHandler */
private $delegate;
/** @var ObjectProphecy */
private $renderer;
/** @var NotFoundRedirectOptions */
private $redirectOptions;
public function setUp(): void
{
$this->renderer = $this->prophesize(TemplateRendererInterface::class);
$this->redirectOptions = new NotFoundRedirectOptions();
$this->delegate = new NotFoundHandler($this->renderer->reveal(), $this->redirectOptions, '');
}
/**
* @test
* @dataProvider provideResponses
*/
public function properResponseTypeIsReturned(string $expectedResponse, string $accept, int $renderCalls): void
{
$request = (new ServerRequest())->withHeader('Accept', $accept);
$render = $this->renderer->render(Argument::cetera())->willReturn('');
$resp = $this->delegate->handle($request);
$this->assertInstanceOf($expectedResponse, $resp);
$render->shouldHaveBeenCalledTimes($renderCalls);
}
public function provideResponses(): iterable
{
yield 'application/json' => [Response\JsonResponse::class, 'application/json', 0];
yield 'text/json' => [Response\JsonResponse::class, 'text/json', 0];
yield 'application/x-json' => [Response\JsonResponse::class, 'application/x-json', 0];
yield 'text/html' => [Response\HtmlResponse::class, 'text/html', 1];
}
/**
* @test
* @dataProvider provideRedirects
*/
public function expectedRedirectionIsReturnedDependingOnTheCase(
ServerRequestInterface $request,
string $expectedRedirectTo
): void {
$this->redirectOptions->invalidShortUrl = 'invalidShortUrl';
$this->redirectOptions->regular404 = 'regular404';
$this->redirectOptions->baseUrl = 'baseUrl';
$resp = $this->delegate->handle($request);
$this->assertInstanceOf(Response\RedirectResponse::class, $resp);
$this->assertEquals($expectedRedirectTo, $resp->getHeaderLine('Location'));
$this->renderer->render(Argument::cetera())->shouldNotHaveBeenCalled();
}
public function provideRedirects(): iterable
{
yield 'base URL with trailing slash' => [
ServerRequestFactory::fromGlobals()->withUri(new Uri('/')),
'baseUrl',
];
yield 'base URL without trailing slash' => [
ServerRequestFactory::fromGlobals()->withUri(new Uri('')),
'baseUrl',
];
yield 'regular 404' => [
ServerRequestFactory::fromGlobals()->withUri(new Uri('/foo/bar')),
'regular404',
];
yield 'invalid short URL' => [
ServerRequestFactory::fromGlobals()
->withAttribute(
RouteResult::class,
RouteResult::fromRoute(
new Route(
'',
$this->prophesize(MiddlewareInterface::class)->reveal(),
['GET'],
RedirectAction::class
)
)
)
->withUri(new Uri('/abc123')),
'invalidShortUrl',
];
}
/**
* @test
* @dataProvider provideTemplates
*/
public function properErrorTemplateIsRendered(ServerRequestInterface $request, string $expectedTemplate): void
{
$request = $request->withHeader('Accept', 'text/html');
$render = $this->renderer->render($expectedTemplate)->willReturn('');
$resp = $this->delegate->handle($request);
$this->assertInstanceOf(Response\HtmlResponse::class, $resp);
$render->shouldHaveBeenCalledOnce();
}
public function provideTemplates(): iterable
{
$request = ServerRequestFactory::fromGlobals();
yield [$request, NotFoundHandler::NOT_FOUND_TEMPLATE];
yield [
$request->withAttribute(
RouteResult::class,
RouteResult::fromRoute(new Route('', $this->prophesize(MiddlewareInterface::class)->reveal()))
),
NotFoundHandler::INVALID_SHORT_CODE_TEMPLATE,
];
}
}

View file

@ -12,7 +12,7 @@ use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
use Shlinkio\Shlink\Core\Service\ShortUrlService;
@ -62,7 +62,7 @@ class ShortUrlServiceTest extends TestCase
->shouldBeCalledOnce();
$this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
$this->expectException(InvalidShortCodeException::class);
$this->expectException(ShortUrlNotFoundException::class);
$this->service->setTagsByShortCode($shortCode);
}

View file

@ -10,7 +10,7 @@ use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
use Shlinkio\Shlink\Core\Repository\TagRepository;
use Shlinkio\Shlink\Core\Service\Tag\TagService;
@ -83,7 +83,7 @@ class TagServiceTest extends TestCase
$find->shouldBeCalled();
$getRepo->shouldBeCalled();
$this->expectException(EntityDoesNotExistException::class);
$this->expectException(TagNotFoundException::class);
$this->service->renameTag('foo', 'bar');
}

View file

@ -48,7 +48,6 @@ return [
Middleware\AuthenticationMiddleware::class => [
Authentication\RequestToHttpAuthPlugin::class,
'config.auth.routes_whitelist',
'Logger_Shlink',
],
],

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Rest;
use Doctrine\DBAL\Connection;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Options\AppOptions;
use Shlinkio\Shlink\Core\Service;
@ -20,15 +21,15 @@ return [
ApiKeyService::class => ConfigAbstractFactory::class,
Action\AuthenticateAction::class => ConfigAbstractFactory::class,
Action\HealthAction::class => Action\HealthActionFactory::class,
Action\HealthAction::class => ConfigAbstractFactory::class,
Action\ShortUrl\CreateShortUrlAction::class => ConfigAbstractFactory::class,
Action\ShortUrl\SingleStepCreateShortUrlAction::class => ConfigAbstractFactory::class,
Action\ShortUrl\EditShortUrlAction::class => ConfigAbstractFactory::class,
Action\ShortUrl\DeleteShortUrlAction::class => ConfigAbstractFactory::class,
Action\ShortUrl\ResolveShortUrlAction::class => ConfigAbstractFactory::class,
Action\Visit\GetVisitsAction::class => ConfigAbstractFactory::class,
Action\ShortUrl\ListShortUrlsAction::class => ConfigAbstractFactory::class,
Action\ShortUrl\EditShortUrlTagsAction::class => ConfigAbstractFactory::class,
Action\Visit\GetVisitsAction::class => ConfigAbstractFactory::class,
Action\Tag\ListTagsAction::class => ConfigAbstractFactory::class,
Action\Tag\DeleteTagsAction::class => ConfigAbstractFactory::class,
Action\Tag\CreateTagsAction::class => ConfigAbstractFactory::class,
@ -38,6 +39,7 @@ return [
Middleware\BodyParserMiddleware::class => InvokableFactory::class,
Middleware\CrossDomainMiddleware::class => InvokableFactory::class,
Middleware\PathVersionMiddleware::class => InvokableFactory::class,
Middleware\BackwardsCompatibleProblemDetailsMiddleware::class => ConfigAbstractFactory::class,
Middleware\ShortUrl\CreateShortUrlContentNegotiationMiddleware::class => InvokableFactory::class,
Middleware\ShortUrl\ShortCodePathMiddleware::class => InvokableFactory::class,
],
@ -48,6 +50,7 @@ return [
ApiKeyService::class => ['em'],
Action\AuthenticateAction::class => [ApiKeyService::class, Authentication\JWTService::class, 'Logger_Shlink'],
Action\HealthAction::class => [Connection::class, AppOptions::class, 'Logger_Shlink'],
Action\ShortUrl\CreateShortUrlAction::class => [
Service\UrlShortener::class,
'config.url_shortener.domain',
@ -73,6 +76,11 @@ return [
Action\Tag\DeleteTagsAction::class => [Service\Tag\TagService::class, LoggerInterface::class],
Action\Tag\CreateTagsAction::class => [Service\Tag\TagService::class, LoggerInterface::class],
Action\Tag\UpdateTagAction::class => [Service\Tag\TagService::class, LoggerInterface::class],
Middleware\BackwardsCompatibleProblemDetailsMiddleware::class => [
'config.backwards_compatible_problem_details.default_type_fallbacks',
'config.backwards_compatible_problem_details.json_flags',
],
],
];

View file

@ -1,21 +0,0 @@
<?php
declare(strict_types=1);
use Shlinkio\Shlink\Rest\ErrorHandler\JsonErrorResponseGenerator;
return [
'error_handler' => [
'plugins' => [
'invokables' => [
'application/json' => JsonErrorResponseGenerator::class,
],
'aliases' => [
'application/x-json' => 'application/json',
'text/json' => 'application/json',
],
],
],
];

View file

@ -10,7 +10,6 @@ use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Rest\Authentication\JWTServiceInterface;
use Shlinkio\Shlink\Rest\Service\ApiKeyService;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use Shlinkio\Shlink\Rest\Util\RestUtils;
use Zend\Diactoros\Response\JsonResponse;
/** @deprecated */
@ -44,7 +43,7 @@ class AuthenticateAction extends AbstractRestAction
$authData = $request->getParsedBody();
if (! isset($authData['apiKey'])) {
return new JsonResponse([
'error' => RestUtils::INVALID_ARGUMENT_ERROR,
'error' => 'INVALID_ARGUMENT',
'message' => 'You have to provide a valid API key under the "apiKey" param name.',
], self::STATUS_BAD_REQUEST);
}
@ -53,7 +52,7 @@ class AuthenticateAction extends AbstractRestAction
$apiKey = $this->apiKeyService->getByKey($authData['apiKey']);
if ($apiKey === null || ! $apiKey->isValid()) {
return new JsonResponse([
'error' => RestUtils::INVALID_API_KEY_ERROR,
'error' => 'INVALID_API_KEY',
'message' => 'Provided API key does not exist or is invalid.',
], self::STATUS_UNAUTHORIZED);
}

View file

@ -15,8 +15,8 @@ use Zend\Diactoros\Response\JsonResponse;
class HealthAction extends AbstractRestAction
{
private const HEALTH_CONTENT_TYPE = 'application/health+json';
private const PASS_STATUS = 'pass';
private const FAIL_STATUS = 'fail';
private const STATUS_PASS = 'pass';
private const STATUS_FAIL = 'fail';
protected const ROUTE_PATH = '/health';
protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET];
@ -48,7 +48,7 @@ class HealthAction extends AbstractRestAction
$statusCode = $connected ? self::STATUS_OK : self::STATUS_SERVICE_UNAVAILABLE;
return new JsonResponse([
'status' => $connected ? self::PASS_STATUS : self::FAIL_STATUS,
'status' => $connected ? self::STATUS_PASS : self::STATUS_FAIL,
'version' => $this->options->getVersion(),
'links' => [
'about' => 'https://shlink.io',

View file

@ -1,20 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Rest\Action;
use Doctrine\ORM\EntityManager;
use Psr\Container\ContainerInterface;
use Shlinkio\Shlink\Core\Options\AppOptions;
class HealthActionFactory
{
public function __invoke(ContainerInterface $container)
{
$em = $container->get(EntityManager::class);
$options = $container->get(AppOptions::class);
$logger = $container->get('Logger_Shlink');
return new HealthAction($em->getConnection(), $options, $logger);
}
}

View file

@ -7,19 +7,13 @@ namespace Shlinkio\Shlink\Rest\Action\ShortUrl;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Exception\InvalidArgumentException;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\Exception\ValidationException;
use Shlinkio\Shlink\Core\Model\CreateShortUrlData;
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
use Shlinkio\Shlink\Rest\Util\RestUtils;
use Throwable;
use Zend\Diactoros\Response\JsonResponse;
use function sprintf;
abstract class AbstractCreateShortUrlAction extends AbstractRestAction
{
/** @var UrlShortenerInterface */
@ -37,56 +31,23 @@ abstract class AbstractCreateShortUrlAction extends AbstractRestAction
$this->domainConfig = $domainConfig;
}
/**
* @param Request $request
* @return Response
*/
public function handle(Request $request): Response
{
try {
$shortUrlData = $this->buildShortUrlData($request);
} catch (InvalidArgumentException $e) {
$this->logger->warning('Provided data is invalid. {e}', ['e' => $e]);
return new JsonResponse([
'error' => RestUtils::INVALID_ARGUMENT_ERROR,
'message' => $e->getMessage(),
], self::STATUS_BAD_REQUEST);
}
$shortUrlData = $this->buildShortUrlData($request);
$longUrl = $shortUrlData->getLongUrl();
$tags = $shortUrlData->getTags();
$shortUrlMeta = $shortUrlData->getMeta();
try {
$shortUrl = $this->urlShortener->urlToShortCode($longUrl, $shortUrlData->getTags(), $shortUrlMeta);
$transformer = new ShortUrlDataTransformer($this->domainConfig);
$shortUrl = $this->urlShortener->urlToShortCode($longUrl, $tags, $shortUrlMeta);
$transformer = new ShortUrlDataTransformer($this->domainConfig);
return new JsonResponse($transformer->transform($shortUrl));
} catch (InvalidUrlException $e) {
$this->logger->warning('Provided Invalid URL. {e}', ['e' => $e]);
return new JsonResponse([
'error' => RestUtils::getRestErrorCodeFromException($e),
'message' => sprintf('Provided URL %s is invalid. Try with a different one.', $longUrl),
], self::STATUS_BAD_REQUEST);
} catch (NonUniqueSlugException $e) {
$customSlug = $shortUrlMeta->getCustomSlug();
$this->logger->warning('Provided non-unique slug. {e}', ['e' => $e]);
return new JsonResponse([
'error' => RestUtils::getRestErrorCodeFromException($e),
'message' => sprintf('Provided slug %s is already in use. Try with a different one.', $customSlug),
], self::STATUS_BAD_REQUEST);
} catch (Throwable $e) {
$this->logger->error('Unexpected error creating short url. {e}', ['e' => $e]);
return new JsonResponse([
'error' => RestUtils::UNKNOWN_ERROR,
'message' => 'Unexpected error occurred',
], self::STATUS_INTERNAL_SERVER_ERROR);
}
return new JsonResponse($transformer->transform($shortUrl));
}
/**
* @param Request $request
* @return CreateShortUrlData
* @throws InvalidArgumentException
* @throws ValidationException
*/
abstract protected function buildShortUrlData(Request $request): CreateShortUrlData;
}

View file

@ -6,7 +6,6 @@ namespace Shlinkio\Shlink\Rest\Action\ShortUrl;
use Cake\Chronos\Chronos;
use Psr\Http\Message\ServerRequestInterface as Request;
use Shlinkio\Shlink\Core\Exception\InvalidArgumentException;
use Shlinkio\Shlink\Core\Exception\ValidationException;
use Shlinkio\Shlink\Core\Model\CreateShortUrlData;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
@ -20,30 +19,27 @@ class CreateShortUrlAction extends AbstractCreateShortUrlAction
/**
* @param Request $request
* @return CreateShortUrlData
* @throws InvalidArgumentException
* @throws \InvalidArgumentException
* @throws ValidationException
*/
protected function buildShortUrlData(Request $request): CreateShortUrlData
{
$postData = (array) $request->getParsedBody();
if (! isset($postData['longUrl'])) {
throw new InvalidArgumentException('A URL was not provided');
throw ValidationException::fromArray([
'longUrl' => 'A URL was not provided',
]);
}
try {
$meta = ShortUrlMeta::createFromParams(
$this->getOptionalDate($postData, 'validSince'),
$this->getOptionalDate($postData, 'validUntil'),
$postData['customSlug'] ?? null,
$postData['maxVisits'] ?? null,
$postData['findIfExists'] ?? null,
$postData['domain'] ?? null
);
$meta = ShortUrlMeta::createFromParams(
$this->getOptionalDate($postData, 'validSince'),
$this->getOptionalDate($postData, 'validUntil'),
$postData['customSlug'] ?? null,
$postData['maxVisits'] ?? null,
$postData['findIfExists'] ?? null,
$postData['domain'] ?? null
);
return new CreateShortUrlData(new Uri($postData['longUrl']), (array) ($postData['tags'] ?? []), $meta);
} catch (ValidationException $e) {
throw new InvalidArgumentException('Provided meta data is not valid', -1, $e);
}
return new CreateShortUrlData(new Uri($postData['longUrl']), (array) ($postData['tags'] ?? []), $meta);
}
private function getOptionalDate(array $postData, string $fieldName): ?Chronos

View file

@ -7,14 +7,9 @@ namespace Shlinkio\Shlink\Rest\Action\ShortUrl;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Exception;
use Shlinkio\Shlink\Core\Service\ShortUrl\DeleteShortUrlServiceInterface;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
use Shlinkio\Shlink\Rest\Util\RestUtils;
use Zend\Diactoros\Response\EmptyResponse;
use Zend\Diactoros\Response\JsonResponse;
use function sprintf;
class DeleteShortUrlAction extends AbstractRestAction
{
@ -30,34 +25,10 @@ class DeleteShortUrlAction extends AbstractRestAction
$this->deleteShortUrlService = $deleteShortUrlService;
}
/**
* Handle the request and return a response.
*/
public function handle(ServerRequestInterface $request): ResponseInterface
{
$shortCode = $request->getAttribute('shortCode', '');
try {
$this->deleteShortUrlService->deleteByShortCode($shortCode);
return new EmptyResponse();
} catch (Exception\InvalidShortCodeException $e) {
$this->logger->warning(
'Provided short code {shortCode} does not belong to any URL. {e}',
['e' => $e, 'shortCode' => $shortCode]
);
return new JsonResponse([
'error' => RestUtils::getRestErrorCodeFromException($e),
'message' => sprintf('No URL found for short code "%s"', $shortCode),
], self::STATUS_NOT_FOUND);
} catch (Exception\DeleteShortUrlException $e) {
$this->logger->warning('Provided data is invalid. {e}', ['e' => $e]);
$messagePlaceholder =
'It is not possible to delete URL with short code "%s" because it has reached more than "%s" visits.';
return new JsonResponse([
'error' => RestUtils::getRestErrorCodeFromException($e),
'message' => sprintf($messagePlaceholder, $shortCode, $e->getVisitsThreshold()),
], self::STATUS_BAD_REQUEST);
}
$this->deleteShortUrlService->deleteByShortCode($shortCode);
return new EmptyResponse();
}
}

View file

@ -7,15 +7,10 @@ namespace Shlinkio\Shlink\Rest\Action\ShortUrl;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Exception;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
use Shlinkio\Shlink\Rest\Util\RestUtils;
use Zend\Diactoros\Response\EmptyResponse;
use Zend\Diactoros\Response\JsonResponse;
use function sprintf;
class EditShortUrlAction extends AbstractRestAction
{
@ -31,38 +26,12 @@ class EditShortUrlAction extends AbstractRestAction
$this->shortUrlService = $shortUrlService;
}
/**
* Process an incoming server request and return a response, optionally delegating
* to the next middleware component to create the response.
*
* @param ServerRequestInterface $request
*
* @return ResponseInterface
* @throws \InvalidArgumentException
*/
public function handle(ServerRequestInterface $request): ResponseInterface
{
$postData = (array) $request->getParsedBody();
$shortCode = $request->getAttribute('shortCode', '');
try {
$this->shortUrlService->updateMetadataByShortCode(
$shortCode,
ShortUrlMeta::createFromRawData($postData)
);
return new EmptyResponse();
} catch (Exception\InvalidShortCodeException $e) {
$this->logger->warning('Provided data is invalid. {e}', ['e' => $e]);
return new JsonResponse([
'error' => RestUtils::getRestErrorCodeFromException($e),
'message' => sprintf('No URL found for short code "%s"', $shortCode),
], self::STATUS_NOT_FOUND);
} catch (Exception\ValidationException $e) {
$this->logger->warning('Provided data is invalid. {e}', ['e' => $e]);
return new JsonResponse([
'error' => RestUtils::getRestErrorCodeFromException($e),
'message' => 'Provided data is invalid.',
], self::STATUS_BAD_REQUEST);
}
$this->shortUrlService->updateMetadataByShortCode($shortCode, ShortUrlMeta::createFromRawData($postData));
return new EmptyResponse();
}
}

View file

@ -7,14 +7,11 @@ namespace Shlinkio\Shlink\Rest\Action\ShortUrl;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
use Shlinkio\Shlink\Core\Exception\ValidationException;
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
use Shlinkio\Shlink\Rest\Util\RestUtils;
use Zend\Diactoros\Response\JsonResponse;
use function sprintf;
class EditShortUrlTagsAction extends AbstractRestAction
{
protected const ROUTE_PATH = '/short-urls/{shortCode}/tags';
@ -29,32 +26,19 @@ class EditShortUrlTagsAction extends AbstractRestAction
$this->shortUrlService = $shortUrlService;
}
/**
* @param Request $request
* @return Response
* @throws \InvalidArgumentException
*/
public function handle(Request $request): Response
{
$shortCode = $request->getAttribute('shortCode');
$bodyParams = $request->getParsedBody();
if (! isset($bodyParams['tags'])) {
return new JsonResponse([
'error' => RestUtils::INVALID_ARGUMENT_ERROR,
'message' => 'A list of tags was not provided',
], self::STATUS_BAD_REQUEST);
throw ValidationException::fromArray([
'tags' => 'List of tags has to be provided',
]);
}
$tags = $bodyParams['tags'];
try {
$shortUrl = $this->shortUrlService->setTagsByShortCode($shortCode, $tags);
return new JsonResponse(['tags' => $shortUrl->getTags()->toArray()]);
} catch (InvalidShortCodeException $e) {
return new JsonResponse([
'error' => RestUtils::getRestErrorCodeFromException($e),
'message' => sprintf('No URL found for short code "%s"', $shortCode),
], self::STATUS_NOT_FOUND);
}
$shortUrl = $this->shortUrlService->setTagsByShortCode($shortCode, $tags);
return new JsonResponse(['tags' => $shortUrl->getTags()->toArray()]);
}
}

View file

@ -4,7 +4,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Rest\Action\ShortUrl;
use Exception;
use InvalidArgumentException;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Log\LoggerInterface;
@ -12,7 +12,6 @@ use Shlinkio\Shlink\Common\Paginator\Util\PaginatorUtilsTrait;
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
use Shlinkio\Shlink\Rest\Util\RestUtils;
use Zend\Diactoros\Response\JsonResponse;
class ListShortUrlsAction extends AbstractRestAction
@ -40,23 +39,15 @@ class ListShortUrlsAction extends AbstractRestAction
/**
* @param Request $request
* @return Response
* @throws \InvalidArgumentException
* @throws InvalidArgumentException
*/
public function handle(Request $request): Response
{
try {
$params = $this->queryToListParams($request->getQueryParams());
$shortUrls = $this->shortUrlService->listShortUrls(...$params);
return new JsonResponse(['shortUrls' => $this->serializePaginator($shortUrls, new ShortUrlDataTransformer(
$this->domainConfig
))]);
} catch (Exception $e) {
$this->logger->error('Unexpected error while listing short URLs. {e}', ['e' => $e]);
return new JsonResponse([
'error' => RestUtils::UNKNOWN_ERROR,
'message' => 'Unexpected error occurred',
], self::STATUS_INTERNAL_SERVER_ERROR);
}
$params = $this->queryToListParams($request->getQueryParams());
$shortUrls = $this->shortUrlService->listShortUrls(...$params);
return new JsonResponse(['shortUrls' => $this->serializePaginator($shortUrls, new ShortUrlDataTransformer(
$this->domainConfig
))]);
}
/**

View file

@ -4,20 +4,15 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Rest\Action\ShortUrl;
use Exception;
use InvalidArgumentException;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
use Shlinkio\Shlink\Rest\Util\RestUtils;
use Zend\Diactoros\Response\JsonResponse;
use function sprintf;
class ResolveShortUrlAction extends AbstractRestAction
{
protected const ROUTE_PATH = '/short-urls/{shortCode}';
@ -41,7 +36,7 @@ class ResolveShortUrlAction extends AbstractRestAction
/**
* @param Request $request
* @return Response
* @throws \InvalidArgumentException
* @throws InvalidArgumentException
*/
public function handle(Request $request): Response
{
@ -49,27 +44,7 @@ class ResolveShortUrlAction extends AbstractRestAction
$domain = $request->getQueryParams()['domain'] ?? null;
$transformer = new ShortUrlDataTransformer($this->domainConfig);
try {
$url = $this->urlShortener->shortCodeToUrl($shortCode, $domain);
return new JsonResponse($transformer->transform($url));
} catch (InvalidShortCodeException $e) {
$this->logger->warning('Provided short code with invalid format. {e}', ['e' => $e]);
return new JsonResponse([
'error' => RestUtils::getRestErrorCodeFromException($e),
'message' => sprintf('Provided short code "%s" has an invalid format', $shortCode),
], self::STATUS_BAD_REQUEST);
} catch (EntityDoesNotExistException $e) {
$this->logger->warning('Provided short code couldn\'t be found. {e}', ['e' => $e]);
return new JsonResponse([
'error' => RestUtils::INVALID_ARGUMENT_ERROR,
'message' => sprintf('No URL found for short code "%s"', $shortCode),
], self::STATUS_NOT_FOUND);
} catch (Exception $e) {
$this->logger->error('Unexpected error while resolving the URL behind a short code. {e}', ['e' => $e]);
return new JsonResponse([
'error' => RestUtils::UNKNOWN_ERROR,
'message' => 'Unexpected error occurred',
], self::STATUS_INTERNAL_SERVER_ERROR);
}
$url = $this->urlShortener->shortCodeToUrl($shortCode, $domain);
return new JsonResponse($transformer->transform($url));
}
}

View file

@ -6,7 +6,7 @@ namespace Shlinkio\Shlink\Rest\Action\ShortUrl;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Exception\InvalidArgumentException;
use Shlinkio\Shlink\Core\Exception\ValidationException;
use Shlinkio\Shlink\Core\Model\CreateShortUrlData;
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
@ -33,19 +33,22 @@ class SingleStepCreateShortUrlAction extends AbstractCreateShortUrlAction
/**
* @param Request $request
* @return CreateShortUrlData
* @throws \InvalidArgumentException
* @throws InvalidArgumentException
* @throws ValidationException
*/
protected function buildShortUrlData(Request $request): CreateShortUrlData
{
$query = $request->getQueryParams();
if (! $this->apiKeyService->check($query['apiKey'] ?? '')) {
throw new InvalidArgumentException('No API key was provided or it is not valid');
throw ValidationException::fromArray([
'apiKey' => 'No API key was provided or it is not valid',
]);
}
if (! isset($query['longUrl'])) {
throw new InvalidArgumentException('A URL was not provided');
throw ValidationException::fromArray([
'longUrl' => 'A URL was not provided',
]);
}
return new CreateShortUrlData(new Uri($query['longUrl']));

View file

@ -7,14 +7,10 @@ namespace Shlinkio\Shlink\Rest\Action\Tag;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException;
use Shlinkio\Shlink\Core\Exception\ValidationException;
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
use Shlinkio\Shlink\Rest\Util\RestUtils;
use Zend\Diactoros\Response\EmptyResponse;
use Zend\Diactoros\Response\JsonResponse;
use function sprintf;
class UpdateTagAction extends AbstractRestAction
{
@ -43,21 +39,13 @@ class UpdateTagAction extends AbstractRestAction
{
$body = $request->getParsedBody();
if (! isset($body['oldName'], $body['newName'])) {
return new JsonResponse([
'error' => RestUtils::INVALID_ARGUMENT_ERROR,
'message' =>
'You have to provide both \'oldName\' and \'newName\' params in order to properly rename the tag',
], self::STATUS_BAD_REQUEST);
throw ValidationException::fromArray([
'oldName' => 'oldName is required',
'newName' => 'newName is required',
]);
}
try {
$this->tagService->renameTag($body['oldName'], $body['newName']);
return new EmptyResponse();
} catch (EntityDoesNotExistException $e) {
return new JsonResponse([
'error' => RestUtils::NOT_FOUND_ERROR,
'message' => sprintf('It was not possible to find a tag with name %s', $body['oldName']),
], self::STATUS_NOT_FOUND);
}
$this->tagService->renameTag($body['oldName'], $body['newName']);
return new EmptyResponse();
}
}

View file

@ -7,16 +7,12 @@ namespace Shlinkio\Shlink\Rest\Action\Visit;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
use Shlinkio\Shlink\Common\Paginator\Util\PaginatorUtilsTrait;
use Shlinkio\Shlink\Core\Model\VisitsParams;
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
use Shlinkio\Shlink\Rest\Util\RestUtils;
use Zend\Diactoros\Response\JsonResponse;
use function sprintf;
class GetVisitsAction extends AbstractRestAction
{
use PaginatorUtilsTrait;
@ -33,27 +29,13 @@ class GetVisitsAction extends AbstractRestAction
$this->visitsTracker = $visitsTracker;
}
/**
* @param Request $request
* @return Response
* @throws \InvalidArgumentException
*/
public function handle(Request $request): Response
{
$shortCode = $request->getAttribute('shortCode');
$visits = $this->visitsTracker->info($shortCode, VisitsParams::fromRawData($request->getQueryParams()));
try {
$visits = $this->visitsTracker->info($shortCode, VisitsParams::fromRawData($request->getQueryParams()));
return new JsonResponse([
'visits' => $this->serializePaginator($visits),
]);
} catch (InvalidArgumentException $e) {
$this->logger->warning('Provided nonexistent short code {e}', ['e' => $e]);
return new JsonResponse([
'error' => RestUtils::getRestErrorCodeFromException($e),
'message' => sprintf('Provided short code %s does not exist', $shortCode),
], self::STATUS_NOT_FOUND);
}
return new JsonResponse([
'visits' => $this->serializePaginator($visits),
]);
}
}

View file

@ -12,6 +12,7 @@ use UnexpectedValueException;
use function time;
/** @deprecated */
class JWTService implements JWTServiceInterface
{
/** @var AppOptions */

View file

@ -8,7 +8,6 @@ use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Rest\Exception\VerifyAuthenticationException;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use Shlinkio\Shlink\Rest\Util\RestUtils;
class ApiKeyHeaderPlugin implements AuthenticationPluginInterface
{
@ -28,14 +27,9 @@ class ApiKeyHeaderPlugin implements AuthenticationPluginInterface
public function verify(ServerRequestInterface $request): void
{
$apiKey = $request->getHeaderLine(self::HEADER_NAME);
if ($this->apiKeyService->check($apiKey)) {
return;
if (! $this->apiKeyService->check($apiKey)) {
throw VerifyAuthenticationException::forInvalidApiKey();
}
throw VerifyAuthenticationException::withError(
RestUtils::INVALID_API_KEY_ERROR,
'Provided API key does not exist or is invalid.'
);
}
public function update(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface

View file

@ -8,7 +8,6 @@ use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Rest\Authentication\JWTServiceInterface;
use Shlinkio\Shlink\Rest\Exception\VerifyAuthenticationException;
use Shlinkio\Shlink\Rest\Util\RestUtils;
use Throwable;
use function count;
@ -16,6 +15,7 @@ use function explode;
use function sprintf;
use function strtolower;
/** @deprecated */
class AuthorizationHeaderPlugin implements AuthenticationPluginInterface
{
public const HEADER_NAME = 'Authorization';
@ -37,19 +37,13 @@ class AuthorizationHeaderPlugin implements AuthenticationPluginInterface
$authToken = $request->getHeaderLine(self::HEADER_NAME);
$authTokenParts = explode(' ', $authToken);
if (count($authTokenParts) === 1) {
throw VerifyAuthenticationException::withError(
RestUtils::INVALID_AUTHORIZATION_ERROR,
sprintf('You need to provide the Bearer type in the %s header.', self::HEADER_NAME)
);
throw VerifyAuthenticationException::forMissingAuthType();
}
// Make sure the authorization type is Bearer
[$authType, $jwt] = $authTokenParts;
if (strtolower($authType) !== 'bearer') {
throw VerifyAuthenticationException::withError(
RestUtils::INVALID_AUTHORIZATION_ERROR,
sprintf('Provided authorization type %s is not supported. Use Bearer instead.', $authType)
);
throw VerifyAuthenticationException::forInvalidAuthType($authType);
}
try {
@ -57,21 +51,13 @@ class AuthorizationHeaderPlugin implements AuthenticationPluginInterface
throw $this->createInvalidTokenError();
}
} catch (Throwable $e) {
throw $this->createInvalidTokenError($e);
throw $this->createInvalidTokenError();
}
}
private function createInvalidTokenError(?Throwable $prev = null): VerifyAuthenticationException
private function createInvalidTokenError(): VerifyAuthenticationException
{
return VerifyAuthenticationException::withError(
RestUtils::INVALID_AUTH_TOKEN_ERROR,
sprintf(
'Missing or invalid auth token provided. Perform a new authentication request and send provided '
. 'token on every new request on the %s header',
self::HEADER_NAME
),
$prev
);
return VerifyAuthenticationException::forInvalidAuthToken();
}
public function update(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface

View file

@ -4,9 +4,8 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Rest\Authentication;
use Psr\Container;
use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Rest\Exception\NoAuthenticationException;
use Shlinkio\Shlink\Rest\Exception\MissingAuthenticationException;
use function array_filter;
use function array_reduce;
@ -30,16 +29,12 @@ class RequestToHttpAuthPlugin implements RequestToHttpAuthPluginInterface
}
/**
* @throws Container\ContainerExceptionInterface
* @throws NoAuthenticationException
* @throws MissingAuthenticationException
*/
public function fromRequest(ServerRequestInterface $request): Plugin\AuthenticationPluginInterface
{
if (! $this->hasAnySupportedHeader($request)) {
throw NoAuthenticationException::fromExpectedTypes([
Plugin\ApiKeyHeaderPlugin::HEADER_NAME,
Plugin\AuthorizationHeaderPlugin::HEADER_NAME,
]);
throw MissingAuthenticationException::fromExpectedTypes(self::SUPPORTED_AUTH_HEADERS);
}
return $this->authPluginManager->get($this->getFirstAvailableHeader($request));

View file

@ -4,15 +4,13 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Rest\Authentication;
use Psr\Container;
use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Rest\Exception\NoAuthenticationException;
use Shlinkio\Shlink\Rest\Exception\MissingAuthenticationException;
interface RequestToHttpAuthPluginInterface
{
/**
* @throws Container\ContainerExceptionInterface
* @throws NoAuthenticationException
* @throws MissingAuthenticationException
*/
public function fromRequest(ServerRequestInterface $request): Plugin\AuthenticationPluginInterface;
}

View file

@ -11,7 +11,7 @@ use function sprintf;
class ConfigProvider
{
private const ROUTES_PREFIX = '/rest/v{version:1}';
private const ROUTES_PREFIX = '/rest/v{version:1|2}';
public function __invoke()
{

View file

@ -1,44 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Rest\ErrorHandler;
use Acelaya\ExpressiveErrorHandler\ErrorHandler\ErrorResponseGeneratorInterface;
use Fig\Http\Message\StatusCodeInterface;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Throwable;
use Zend\Diactoros\Response\JsonResponse;
use function str_replace;
use function strtoupper;
class JsonErrorResponseGenerator implements ErrorResponseGeneratorInterface, StatusCodeInterface
{
/**
* Final handler for an application.
*
* @param \Throwable|\Exception $e
* @param Request $request
* @param Response $response
* @return Response
* @throws \InvalidArgumentException
*/
public function __invoke(?Throwable $e, Request $request, Response $response)
{
$status = $response->getStatusCode();
$responsePhrase = $status < 400 ? 'Internal Server Error' : $response->getReasonPhrase();
$status = $status < 400 ? self::STATUS_INTERNAL_SERVER_ERROR : $status;
return new JsonResponse([
'error' => $this->responsePhraseToCode($responsePhrase),
'message' => $responsePhrase,
], $status);
}
private function responsePhraseToCode(string $responsePhrase): string
{
return strtoupper(str_replace(' ', '_', $responsePhrase));
}
}

View file

@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Rest\Exception;
use Throwable;
/** @deprecated */
class AuthenticationException extends RuntimeException
{
public static function expiredJWT(?Throwable $prev = null): self

View file

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Rest\Exception;
use Fig\Http\Message\StatusCodeInterface;
use Zend\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait;
use Zend\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
use function implode;
use function sprintf;
class MissingAuthenticationException extends RuntimeException implements ProblemDetailsExceptionInterface
{
use CommonProblemDetailsExceptionTrait;
private const TITLE = 'Invalid authorization';
private const TYPE = 'INVALID_AUTHORIZATION';
public static function fromExpectedTypes(array $expectedTypes): self
{
$e = new self(sprintf(
'Expected one of the following authentication headers, but none were provided, ["%s"]',
implode('", "', $expectedTypes)
));
$e->detail = $e->getMessage();
$e->title = self::TITLE;
$e->type = self::TYPE;
$e->status = StatusCodeInterface::STATUS_UNAUTHORIZED;
$e->additional = ['expectedTypes' => $expectedTypes];
return $e;
}
}

View file

@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Rest\Exception;
use function implode;
use function sprintf;
class NoAuthenticationException extends RuntimeException
{
public static function fromExpectedTypes(array $expectedTypes): self
{
return new self(sprintf(
'None of the valid authentication mechanisms where provided. Expected one of ["%s"]',
implode('", "', $expectedTypes)
));
}
}

View file

@ -4,47 +4,67 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Rest\Exception;
use Throwable;
use Fig\Http\Message\StatusCodeInterface;
use Zend\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait;
use Zend\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
use function sprintf;
class VerifyAuthenticationException extends RuntimeException
class VerifyAuthenticationException extends RuntimeException implements ProblemDetailsExceptionInterface
{
/** @var string */
private $errorCode;
/** @var string */
private $publicMessage;
use CommonProblemDetailsExceptionTrait;
public function __construct(
string $errorCode,
string $publicMessage,
string $message = '',
int $code = 0,
?Throwable $previous = null
) {
parent::__construct($message, $code, $previous);
$this->errorCode = $errorCode;
$this->publicMessage = $publicMessage;
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->status = StatusCodeInterface::STATUS_UNAUTHORIZED;
return $e;
}
public static function withError(string $errorCode, string $publicMessage, ?Throwable $prev = null): self
/** @deprecated */
public static function forInvalidAuthToken(): self
{
return new self(
$errorCode,
$publicMessage,
sprintf('Authentication verification failed with the public message "%s"', $publicMessage),
0,
$prev
$e = new self(
'Missing or invalid auth token provided. Perform a new authentication request and send provided '
. 'token on every new request on the Authorization header'
);
$e->detail = $e->getMessage();
$e->title = 'Invalid auth token';
$e->type = 'INVALID_AUTH_TOKEN';
$e->status = StatusCodeInterface::STATUS_UNAUTHORIZED;
return $e;
}
public function getErrorCode(): string
/** @deprecated */
public static function forMissingAuthType(): self
{
return $this->errorCode;
$e = new self('You need to provide the Bearer type in the Authorization header.');
$e->detail = $e->getMessage();
$e->title = 'Invalid authorization';
$e->type = 'INVALID_AUTHORIZATION';
$e->status = StatusCodeInterface::STATUS_UNAUTHORIZED;
return $e;
}
public function getPublicMessage(): string
/** @deprecated */
public static function forInvalidAuthType(string $providedType): self
{
return $this->publicMessage;
$e = new self(sprintf('Provided authorization type %s is not supported. Use Bearer instead.', $providedType));
$e->detail = $e->getMessage();
$e->title = 'Invalid authorization';
$e->type = 'INVALID_AUTHORIZATION';
$e->status = StatusCodeInterface::STATUS_UNAUTHORIZED;
return $e;
}
}

View file

@ -6,54 +6,28 @@ namespace Shlinkio\Shlink\Rest\Middleware;
use Fig\Http\Message\RequestMethodInterface;
use Fig\Http\Message\StatusCodeInterface;
use Psr\Container\ContainerExceptionInterface;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Shlinkio\Shlink\Rest\Authentication\RequestToHttpAuthPlugin;
use Shlinkio\Shlink\Rest\Authentication\RequestToHttpAuthPluginInterface;
use Shlinkio\Shlink\Rest\Exception\NoAuthenticationException;
use Shlinkio\Shlink\Rest\Exception\VerifyAuthenticationException;
use Shlinkio\Shlink\Rest\Util\RestUtils;
use Zend\Diactoros\Response\JsonResponse;
use Zend\Expressive\Router\RouteResult;
use function Functional\contains;
use function implode;
use function sprintf;
class AuthenticationMiddleware implements MiddlewareInterface, StatusCodeInterface, RequestMethodInterface
{
/** @var LoggerInterface */
private $logger;
/** @var array */
private $routesWhitelist;
/** @var RequestToHttpAuthPluginInterface */
private $requestToAuthPlugin;
public function __construct(
RequestToHttpAuthPluginInterface $requestToAuthPlugin,
array $routesWhitelist,
?LoggerInterface $logger = null
) {
public function __construct(RequestToHttpAuthPluginInterface $requestToAuthPlugin, array $routesWhitelist)
{
$this->routesWhitelist = $routesWhitelist;
$this->requestToAuthPlugin = $requestToAuthPlugin;
$this->logger = $logger ?: new NullLogger();
}
/**
* Process an incoming server request and return a response, optionally delegating
* to the next middleware component to create the response.
*
* @param Request $request
* @param RequestHandlerInterface $handler
*
* @return Response
* @throws \InvalidArgumentException
*/
public function process(Request $request, RequestHandlerInterface $handler): Response
{
/** @var RouteResult|null $routeResult */
@ -67,33 +41,10 @@ class AuthenticationMiddleware implements MiddlewareInterface, StatusCodeInterfa
return $handler->handle($request);
}
try {
$plugin = $this->requestToAuthPlugin->fromRequest($request);
} catch (ContainerExceptionInterface | NoAuthenticationException $e) {
$this->logger->warning('Invalid or no authentication provided. {e}', ['e' => $e]);
return $this->createErrorResponse(sprintf(
'Expected one of the following authentication headers, but none were provided, ["%s"]',
implode('", "', RequestToHttpAuthPlugin::SUPPORTED_AUTH_HEADERS)
));
}
$plugin = $this->requestToAuthPlugin->fromRequest($request);
$plugin->verify($request);
$response = $handler->handle($request);
try {
$plugin->verify($request);
$response = $handler->handle($request);
return $plugin->update($request, $response);
} catch (VerifyAuthenticationException $e) {
$this->logger->warning('Authentication verification failed. {e}', ['e' => $e]);
return $this->createErrorResponse($e->getPublicMessage(), $e->getErrorCode());
}
}
private function createErrorResponse(
string $message,
string $errorCode = RestUtils::INVALID_AUTHORIZATION_ERROR
): JsonResponse {
return new JsonResponse([
'error' => $errorCode,
'message' => $message,
], self::STATUS_UNAUTHORIZED);
return $plugin->update($request, $response);
}
}

Some files were not shown because too many files have changed in this diff Show more