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 vendor/bin/zend-expressive-swoole start -d
sleep 2 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=$? testsExitCode=$?
vendor/bin/zend-expressive-swoole stop vendor/bin/zend-expressive-swoole stop

View file

@ -15,7 +15,6 @@
"php": "^7.2", "php": "^7.2",
"ext-json": "*", "ext-json": "*",
"ext-pdo": "*", "ext-pdo": "*",
"acelaya/ze-content-based-error-handler": "^3.0",
"akrabat/ip-address-middleware": "^1.0", "akrabat/ip-address-middleware": "^1.0",
"cakephp/chronos": "^1.2", "cakephp/chronos": "^1.2",
"cocur/slugify": "^3.0", "cocur/slugify": "^3.0",
@ -34,7 +33,7 @@
"phly/phly-event-dispatcher": "^1.0", "phly/phly-event-dispatcher": "^1.0",
"predis/predis": "^1.1", "predis/predis": "^1.1",
"pugx/shortid-php": "^0.5", "pugx/shortid-php": "^0.5",
"shlinkio/shlink-common": "^2.2.1", "shlinkio/shlink-common": "^2.3",
"shlinkio/shlink-event-dispatcher": "^1.0", "shlinkio/shlink-event-dispatcher": "^1.0",
"shlinkio/shlink-installer": "^3.1", "shlinkio/shlink-installer": "^3.1",
"shlinkio/shlink-ip-geolocation": "^1.1", "shlinkio/shlink-ip-geolocation": "^1.1",
@ -53,20 +52,20 @@
"zendframework/zend-expressive-swoole": "^2.4", "zendframework/zend-expressive-swoole": "^2.4",
"zendframework/zend-inputfilter": "^2.10", "zendframework/zend-inputfilter": "^2.10",
"zendframework/zend-paginator": "^2.8", "zendframework/zend-paginator": "^2.8",
"zendframework/zend-problem-details": "^1.0",
"zendframework/zend-servicemanager": "^3.4", "zendframework/zend-servicemanager": "^3.4",
"zendframework/zend-stdlib": "^3.2" "zendframework/zend-stdlib": "^3.2"
}, },
"require-dev": { "require-dev": {
"devster/ubench": "^2.0", "devster/ubench": "^2.0",
"eaglewu/swoole-ide-helper": "dev-master", "eaglewu/swoole-ide-helper": "dev-master",
"filp/whoops": "^2.4",
"infection/infection": "^0.14.2", "infection/infection": "^0.14.2",
"phpstan/phpstan": "^0.11.16", "phpstan/phpstan": "^0.11.16",
"phpunit/phpcov": "^6.0", "phpunit/phpcov": "^6.0",
"phpunit/phpunit": "^8.3", "phpunit/phpunit": "^8.3",
"roave/security-advisories": "dev-master", "roave/security-advisories": "dev-master",
"shlinkio/php-coding-standard": "~2.0.0", "shlinkio/php-coding-standard": "~2.0.0",
"shlinkio/shlink-test-utils": "^1.0", "shlinkio/shlink-test-utils": "^1.1",
"symfony/dotenv": "^4.3", "symfony/dotenv": "^4.3",
"symfony/var-dumper": "^4.3", "symfony/var-dumper": "^4.3",
"zendframework/zend-component-installer": "^2.1", "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; namespace Shlinkio\Shlink;
use Zend\Expressive; use Zend\Expressive;
use Zend\ProblemDetails;
use Zend\Stratigility\Middleware\ErrorHandler; use Zend\Stratigility\Middleware\ErrorHandler;
return [ return [
'middleware_pipeline' => [ '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' => [ 'pre-routing' => [
'middleware' => [ 'middleware' => [
ErrorHandler::class,
Expressive\Helper\ContentLengthMiddleware::class,
Common\Middleware\CloseDbConnectionMiddleware::class, Common\Middleware\CloseDbConnectionMiddleware::class,
], ],
'priority' => 12,
], ],
'pre-routing-rest' => [ 'pre-routing-rest' => [
'path' => '/rest', 'path' => '/rest',
@ -24,33 +37,40 @@ return [
Rest\Middleware\PathVersionMiddleware::class, Rest\Middleware\PathVersionMiddleware::class,
Rest\Middleware\ShortUrl\ShortCodePathMiddleware::class, Rest\Middleware\ShortUrl\ShortCodePathMiddleware::class,
], ],
'priority' => 11,
], ],
'routing' => [ 'routing' => [
'middleware' => [ 'middleware' => [
Expressive\Router\Middleware\RouteMiddleware::class, Expressive\Router\Middleware\RouteMiddleware::class,
], ],
'priority' => 10,
], ],
'rest' => [ 'rest' => [
'path' => '/rest', 'path' => '/rest',
'middleware' => [ 'middleware' => [
Rest\Middleware\CrossDomainMiddleware::class,
Expressive\Router\Middleware\ImplicitOptionsMiddleware::class, Expressive\Router\Middleware\ImplicitOptionsMiddleware::class,
Rest\Middleware\BodyParserMiddleware::class, Rest\Middleware\BodyParserMiddleware::class,
Rest\Middleware\AuthenticationMiddleware::class, Rest\Middleware\AuthenticationMiddleware::class,
], ],
'priority' => 5,
], ],
'post-routing' => [ 'dispatch' => [
'middleware' => [ 'middleware' => [
Expressive\Router\Middleware\DispatchMiddleware::class, 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; namespace Shlinkio\Shlink;
use Acelaya\ExpressiveErrorHandler;
use Zend\ConfigAggregator; use Zend\ConfigAggregator;
use Zend\Expressive; use Zend\Expressive;
use Zend\ProblemDetails;
use function Shlinkio\Shlink\Common\env; use function Shlinkio\Shlink\Common\env;
@ -16,7 +16,7 @@ return (new ConfigAggregator\ConfigAggregator([
Expressive\Router\FastRouteRouter\ConfigProvider::class, Expressive\Router\FastRouteRouter\ConfigProvider::class,
Expressive\Plates\ConfigProvider::class, Expressive\Plates\ConfigProvider::class,
Expressive\Swoole\ConfigProvider::class, Expressive\Swoole\ConfigProvider::class,
ExpressiveErrorHandler\ConfigProvider::class, ProblemDetails\ConfigProvider::class,
Common\ConfigProvider::class, Common\ConfigProvider::class,
IpGeolocation\ConfigProvider::class, IpGeolocation\ConfigProvider::class,
Core\ConfigProvider::class, Core\ConfigProvider::class,

View file

@ -1,13 +1,32 @@
{ {
"type": "object", "type": "object",
"required": ["type", "title", "detail", "status"],
"properties": { "properties": {
"code": { "type": {
"type": "string", "type": "string",
"description": "A machine unique code" "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": { "message": {
"type": "string", "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", "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.", "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": [ "parameters": [
{
"$ref": "../parameters/version.json"
},
{ {
"name": "page", "name": "page",
"in": "query", "in": "query",
@ -150,7 +153,7 @@
"500": { "500": {
"description": "Unexpected error.", "description": "Unexpected error.",
"content": { "content": {
"application/json": { "application/problem+json": {
"schema": { "schema": {
"$ref": "../definitions/Error.json" "$ref": "../definitions/Error.json"
} }
@ -175,6 +178,11 @@
"Bearer": [] "Bearer": []
} }
], ],
"parameters": [
{
"$ref": "../parameters/version.json"
}
],
"requestBody": { "requestBody": {
"description": "Request body.", "description": "Request body.",
"required": true, "required": true,
@ -256,11 +264,43 @@
} }
}, },
"400": { "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": { "content": {
"application/json": { "application/problem+json": {
"schema": { "schema": {
"type": "object",
"allOf": [
{
"$ref": "../definitions/Error.json" "$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": { "500": {
"description": "Unexpected error.", "description": "Unexpected error.",
"content": { "content": {
"application/json": { "application/problem+json": {
"schema": { "schema": {
"$ref": "../definitions/Error.json" "$ref": "../definitions/Error.json"
} }

View file

@ -7,6 +7,9 @@
"summary": "Create a short URL", "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.", "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": [ "parameters": [
{
"$ref": "../parameters/version.json"
},
{ {
"name": "apiKey", "name": "apiKey",
"in": "query", "in": "query",
@ -77,7 +80,7 @@
"400": { "400": {
"description": "The long URL was not provided or is invalid.", "description": "The long URL was not provided or is invalid.",
"content": { "content": {
"application/json": { "application/problem+json": {
"schema": { "schema": {
"$ref": "../definitions/Error.json" "$ref": "../definitions/Error.json"
} }
@ -89,9 +92,12 @@
} }
}, },
"examples": { "examples": {
"application/json": { "application/problem+json": {
"error": "INVALID_URL", "title": "Invalid URL",
"message": "Provided URL foo is invalid. Try with a different one." "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" "text/plain": "INVALID_URL"
} }
@ -99,7 +105,7 @@
"500": { "500": {
"description": "Unexpected error.", "description": "Unexpected error.",
"content": { "content": {
"application/json": { "application/problem+json": {
"schema": { "schema": {
"$ref": "../definitions/Error.json" "$ref": "../definitions/Error.json"
} }
@ -111,11 +117,11 @@
} }
}, },
"examples": { "examples": {
"application/json": { "application/problem+json": {
"error": "UNKNOWN_ERROR", "error": "INTERNAL_SERVER_ERROR",
"message": "Unexpected error occurred" "message": "Unexpected error occurred"
}, },
"text/plain": "UNKNOWN_ERROR" "text/plain": "INTERNAL_SERVER_ERROR"
} }
} }
} }

View file

@ -7,6 +7,9 @@
"summary": "Parse short code", "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.", "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": [ "parameters": [
{
"$ref": "../parameters/version.json"
},
{ {
"name": "shortCode", "name": "shortCode",
"in": "path", "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": { "404": {
"description": "No URL was found for provided short code.", "description": "No URL was found for provided short code.",
"content": { "content": {
"application/json": { "application/problem+json": {
"schema": { "schema": {
"$ref": "../definitions/Error.json" "$ref": "../definitions/Error.json"
} }
@ -85,7 +78,7 @@
"500": { "500": {
"description": "Unexpected error.", "description": "Unexpected error.",
"content": { "content": {
"application/json": { "application/problem+json": {
"schema": { "schema": {
"$ref": "../definitions/Error.json" "$ref": "../definitions/Error.json"
} }
@ -103,6 +96,9 @@
"summary": "Edit short URL", "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.", "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": [ "parameters": [
{
"$ref": "../parameters/version.json"
},
{ {
"name": "shortCode", "name": "shortCode",
"in": "path", "in": "path",
@ -153,9 +149,31 @@
"400": { "400": {
"description": "Provided meta arguments are invalid.", "description": "Provided meta arguments are invalid.",
"content": { "content": {
"application/json": { "application/problem+json": {
"schema": { "schema": {
"type": "object",
"allOf": [
{
"$ref": "../definitions/Error.json" "$ref": "../definitions/Error.json"
},
{
"type": "object",
"required": ["invalidElements"],
"properties": {
"invalidElements": {
"type": "array",
"items": {
"type": "string",
"enum": [
"validSince",
"validUntil",
"maxVisits"
]
}
}
}
}
]
} }
} }
} }
@ -163,7 +181,7 @@
"404": { "404": {
"description": "No short URL was found for provided short code.", "description": "No short URL was found for provided short code.",
"content": { "content": {
"application/json": { "application/problem+json": {
"schema": { "schema": {
"$ref": "../definitions/Error.json" "$ref": "../definitions/Error.json"
} }
@ -173,7 +191,7 @@
"500": { "500": {
"description": "Unexpected error.", "description": "Unexpected error.",
"content": { "content": {
"application/json": { "application/problem+json": {
"schema": { "schema": {
"$ref": "../definitions/Error.json" "$ref": "../definitions/Error.json"
} }
@ -192,6 +210,9 @@
"summary": "[DEPRECATED] Edit short URL", "summary": "[DEPRECATED] Edit short URL",
"description": "**[DEPRECATED]** Use [editShortUrl](#/Short_URLs/getShortUrl) instead", "description": "**[DEPRECATED]** Use [editShortUrl](#/Short_URLs/getShortUrl) instead",
"parameters": [ "parameters": [
{
"$ref": "../parameters/version.json"
},
{ {
"name": "shortCode", "name": "shortCode",
"in": "path", "in": "path",
@ -242,7 +263,7 @@
"400": { "400": {
"description": "Provided meta arguments are invalid.", "description": "Provided meta arguments are invalid.",
"content": { "content": {
"application/json": { "application/problem+json": {
"schema": { "schema": {
"$ref": "../definitions/Error.json" "$ref": "../definitions/Error.json"
} }
@ -252,7 +273,7 @@
"404": { "404": {
"description": "No short URL was found for provided short code.", "description": "No short URL was found for provided short code.",
"content": { "content": {
"application/json": { "application/problem+json": {
"schema": { "schema": {
"$ref": "../definitions/Error.json" "$ref": "../definitions/Error.json"
} }
@ -262,7 +283,7 @@
"500": { "500": {
"description": "Unexpected error.", "description": "Unexpected error.",
"content": { "content": {
"application/json": { "application/problem+json": {
"schema": { "schema": {
"$ref": "../definitions/Error.json" "$ref": "../definitions/Error.json"
} }
@ -280,6 +301,9 @@
"summary": "Delete short URL", "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.", "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": [ "parameters": [
{
"$ref": "../parameters/version.json"
},
{ {
"name": "shortCode", "name": "shortCode",
"in": "path", "in": "path",
@ -302,26 +326,28 @@
"204": { "204": {
"description": "The short URL has been properly deleted." "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.", "description": "The visits threshold in shlink does not allow this short URL to be deleted.",
"content": { "content": {
"application/json": { "application/problem+json": {
"schema": { "schema": {
"$ref": "../definitions/Error.json" "$ref": "../definitions/Error.json"
} }
} }
}, },
"examples": { "examples": {
"application/json": { "application/problem+json": {
"error": "INVALID_SHORTCODE_DELETION", "title": "Cannot delete short URL",
"message": "It is not possible to delete URL with short code \"abc123\" because it has reached more than \"15\" visits." "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": { "404": {
"description": "No short URL was found for provided short code.", "description": "No short URL was found for provided short code.",
"content": { "content": {
"application/json": { "application/problem+json": {
"schema": { "schema": {
"$ref": "../definitions/Error.json" "$ref": "../definitions/Error.json"
} }
@ -331,7 +357,7 @@
"500": { "500": {
"description": "Unexpected error.", "description": "Unexpected error.",
"content": { "content": {
"application/json": { "application/problem+json": {
"schema": { "schema": {
"$ref": "../definitions/Error.json" "$ref": "../definitions/Error.json"
} }

View file

@ -7,6 +7,9 @@
"summary": "Edit tags on short URL", "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.", "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": [ "parameters": [
{
"$ref": "../parameters/version.json"
},
{ {
"name": "shortCode", "name": "shortCode",
"in": "path", "in": "path",
@ -78,7 +81,7 @@
"400": { "400": {
"description": "The request body does not contain a \"tags\" param with array type.", "description": "The request body does not contain a \"tags\" param with array type.",
"content": { "content": {
"application/json": { "application/problem+json": {
"schema": { "schema": {
"$ref": "../definitions/Error.json" "$ref": "../definitions/Error.json"
} }

View file

@ -7,6 +7,9 @@
"summary": "List visits for short URL", "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.", "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": [ "parameters": [
{
"$ref": "../parameters/version.json"
},
{ {
"name": "shortCode", "name": "shortCode",
"in": "path", "in": "path",
@ -132,7 +135,7 @@
"404": { "404": {
"description": "The short code does not belong to any short URL.", "description": "The short code does not belong to any short URL.",
"content": { "content": {
"application/json": { "application/problem+json": {
"schema": { "schema": {
"$ref": "../definitions/Error.json" "$ref": "../definitions/Error.json"
} }
@ -142,7 +145,7 @@
"500": { "500": {
"description": "Unexpected error.", "description": "Unexpected error.",
"content": { "content": {
"application/json": { "application/problem+json": {
"schema": { "schema": {
"$ref": "../definitions/Error.json" "$ref": "../definitions/Error.json"
} }

View file

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

View file

@ -20,16 +20,6 @@
"responses": { "responses": {
"302": { "302": {
"description": "Visit properly tracked and redirected" "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": { "paths": {
"/rest/v1/short-urls": { "/rest/v{version}/short-urls": {
"$ref": "paths/v1_short-urls.json" "$ref": "paths/v1_short-urls.json"
}, },
"/rest/v1/short-urls/shorten": { "/rest/v{version}/short-urls/shorten": {
"$ref": "paths/v1_short-urls_shorten.json" "$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" "$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" "$ref": "paths/v1_short-urls_{shortCode}_tags.json"
}, },
"/rest/v1/tags": { "/rest/v{version}/tags": {
"$ref": "paths/v1_tags.json" "$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" "$ref": "paths/v1_short-urls_{shortCode}_visits.json"
}, },

View file

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

View file

@ -55,22 +55,17 @@ class DeleteShortUrlCommand extends Command
try { try {
$this->runDelete($io, $shortCode, $ignoreThreshold); $this->runDelete($io, $shortCode, $ignoreThreshold);
return ExitCodes::EXIT_SUCCESS; return ExitCodes::EXIT_SUCCESS;
} catch (Exception\InvalidShortCodeException $e) { } catch (Exception\ShortUrlNotFoundException $e) {
$io->error(sprintf('Provided short code "%s" could not be found.', $shortCode)); $io->error($e->getMessage());
return ExitCodes::EXIT_FAILURE; return ExitCodes::EXIT_FAILURE;
} catch (Exception\DeleteShortUrlException $e) { } 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( $io->writeln(sprintf('<bg=yellow>%s</>', $warningMsg));
'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 . '</>');
$forceDelete = $io->confirm('Do you want to delete it anyway?', false); $forceDelete = $io->confirm('Do you want to delete it anyway?', false);
if ($forceDelete) { if ($forceDelete) {

View file

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

View file

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

View file

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

View file

@ -12,20 +12,16 @@ class GeolocationDbUpdateFailedException extends RuntimeException implements Exc
/** @var bool */ /** @var bool */
private $olderDbExists; 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 public static function create(bool $olderDbExists, ?Throwable $prev = null): self
{ {
return new self( $e = new self(
$olderDbExists,
'An error occurred while updating geolocation database, and an older version could not be found', 'An error occurred while updating geolocation database, and an older version could not be found',
0, 0,
$prev $prev
); );
$e->olderDbExists = $olderDbExists;
return $e;
} }
public function olderDbExists(): bool 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\Exception\RuntimeException;
use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdaterInterface; use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdaterInterface;
use Symfony\Component\Lock\Factory as Locker; use Symfony\Component\Lock\Factory as Locker;
use Throwable;
class GeolocationDbUpdater implements GeolocationDbUpdaterInterface class GeolocationDbUpdater implements GeolocationDbUpdaterInterface
{ {
@ -40,8 +39,6 @@ class GeolocationDbUpdater implements GeolocationDbUpdaterInterface
try { try {
$this->downloadIfNeeded($mustBeUpdated, $handleProgress); $this->downloadIfNeeded($mustBeUpdated, $handleProgress);
} catch (Throwable $e) {
throw $e;
} finally { } finally {
$lock->release(); $lock->release();
} }

View file

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

View file

@ -58,13 +58,13 @@ class DeleteShortUrlCommandTest extends TestCase
{ {
$shortCode = 'abc123'; $shortCode = 'abc123';
$deleteByShortCode = $this->service->deleteByShortCode($shortCode, false)->willThrow( $deleteByShortCode = $this->service->deleteByShortCode($shortCode, false)->willThrow(
Exception\InvalidShortCodeException::class Exception\ShortUrlNotFoundException::fromNotFoundShortCode($shortCode)
); );
$this->commandTester->execute(['shortCode' => $shortCode]); $this->commandTester->execute(['shortCode' => $shortCode]);
$output = $this->commandTester->getDisplay(); $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(); $deleteByShortCode->shouldHaveBeenCalledOnce();
} }
@ -79,11 +79,11 @@ class DeleteShortUrlCommandTest extends TestCase
): void { ): void {
$shortCode = 'abc123'; $shortCode = 'abc123';
$deleteByShortCode = $this->service->deleteByShortCode($shortCode, Argument::type('bool'))->will( $deleteByShortCode = $this->service->deleteByShortCode($shortCode, Argument::type('bool'))->will(
function (array $args) { function (array $args) use ($shortCode) {
$ignoreThreshold = array_pop($args); $ignoreThreshold = array_pop($args);
if (!$ignoreThreshold) { 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(); $output = $this->commandTester->getDisplay();
$this->assertStringContainsString(sprintf( $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 $shortCode
), $output); ), $output);
$this->assertStringContainsString($expectedMessage, $output); $this->assertStringContainsString($expectedMessage, $output);
@ -112,7 +112,7 @@ class DeleteShortUrlCommandTest extends TestCase
{ {
$shortCode = 'abc123'; $shortCode = 'abc123';
$deleteByShortCode = $this->service->deleteByShortCode($shortCode, false)->willThrow( $deleteByShortCode = $this->service->deleteByShortCode($shortCode, false)->willThrow(
new Exception\DeleteShortUrlException(10) Exception\DeleteShortUrlException::fromVisitsThreshold(10, $shortCode)
); );
$this->commandTester->setInputs(['no']); $this->commandTester->setInputs(['no']);
@ -120,7 +120,7 @@ class DeleteShortUrlCommandTest extends TestCase
$output = $this->commandTester->getDisplay(); $output = $this->commandTester->getDisplay();
$this->assertStringContainsString(sprintf( $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 $shortCode
), $output); ), $output);
$this->assertStringContainsString('Short URL was not deleted.', $output); $this->assertStringContainsString('Short URL was not deleted.', $output);

View file

@ -59,21 +59,22 @@ class GenerateShortUrlCommandTest extends TestCase
/** @test */ /** @test */
public function exceptionWhileParsingLongUrlOutputsError(): void 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(); ->shouldBeCalledOnce();
$this->commandTester->execute(['longUrl' => 'http://domain.com/invalid']); $this->commandTester->execute(['longUrl' => $url]);
$output = $this->commandTester->getDisplay(); $output = $this->commandTester->getDisplay();
$this->assertEquals(ExitCodes::EXIT_FAILURE, $this->commandTester->getStatusCode()); $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 */ /** @test */
public function providingNonUniqueSlugOutputsError(): void public function providingNonUniqueSlugOutputsError(): void
{ {
$urlToShortCode = $this->urlShortener->urlToShortCode(Argument::cetera())->willThrow( $urlToShortCode = $this->urlShortener->urlToShortCode(Argument::cetera())->willThrow(
NonUniqueSlugException::class NonUniqueSlugException::fromSlug('my-slug')
); );
$this->commandTester->execute(['longUrl' => 'http://domain.com/invalid', '--customSlug' => '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 Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\ShortUrl\ResolveUrlCommand; use Shlinkio\Shlink\CLI\Command\ShortUrl\ResolveUrlCommand;
use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
use Shlinkio\Shlink\Core\Service\UrlShortener; use Shlinkio\Shlink\Core\Service\UrlShortener;
use Symfony\Component\Console\Application; use Symfony\Component\Console\Application;
use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Console\Tester\CommandTester;
use function sprintf;
use const PHP_EOL; use const PHP_EOL;
class ResolveUrlCommandTest extends TestCase class ResolveUrlCommandTest extends TestCase
@ -51,23 +52,12 @@ class ResolveUrlCommandTest extends TestCase
public function incorrectShortCodeOutputsErrorMessage(): void public function incorrectShortCodeOutputsErrorMessage(): void
{ {
$shortCode = 'abc123'; $shortCode = 'abc123';
$this->urlShortener->shortCodeToUrl($shortCode, null)->willThrow(EntityDoesNotExistException::class) $this->urlShortener->shortCodeToUrl($shortCode, null)
->willThrow(ShortUrlNotFoundException::fromNotFoundShortCode($shortCode))
->shouldBeCalledOnce(); ->shouldBeCalledOnce();
$this->commandTester->execute(['shortCode' => $shortCode]); $this->commandTester->execute(['shortCode' => $shortCode]);
$output = $this->commandTester->getDisplay(); $output = $this->commandTester->getDisplay();
$this->assertStringContainsString('Provided short code "' . $shortCode . '" could not be found.', $output); $this->assertStringContainsString(sprintf('No URL found with short code "%s"', $shortCode), $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);
} }
} }

View file

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

View file

@ -12,47 +12,6 @@ use Throwable;
class GeolocationDbUpdateFailedExceptionTest extends TestCase 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 * @test
* @dataProvider provideCreateArgs * @dataProvider provideCreateArgs

View file

@ -6,8 +6,8 @@ namespace Shlinkio\Shlink\Core;
use Doctrine\Common\Cache\Cache; use Doctrine\Common\Cache\Cache;
use Psr\EventDispatcher\EventDispatcherInterface; use Psr\EventDispatcher\EventDispatcherInterface;
use Shlinkio\Shlink\Core\ErrorHandler;
use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions; use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions;
use Shlinkio\Shlink\Core\Response\NotFoundHandler;
use Shlinkio\Shlink\PreviewGenerator\Service\PreviewGenerator; use Shlinkio\Shlink\PreviewGenerator\Service\PreviewGenerator;
use Zend\Expressive\Router\RouterInterface; use Zend\Expressive\Router\RouterInterface;
use Zend\Expressive\Template\TemplateRendererInterface; use Zend\Expressive\Template\TemplateRendererInterface;
@ -17,7 +17,8 @@ return [
'dependencies' => [ 'dependencies' => [
'factories' => [ 'factories' => [
NotFoundHandler::class => ConfigAbstractFactory::class, ErrorHandler\NotFoundRedirectHandler::class => ConfigAbstractFactory::class,
ErrorHandler\NotFoundTemplateHandler::class => ConfigAbstractFactory::class,
Options\AppOptions::class => ConfigAbstractFactory::class, Options\AppOptions::class => ConfigAbstractFactory::class,
Options\DeleteShortUrlsOptions::class => ConfigAbstractFactory::class, Options\DeleteShortUrlsOptions::class => ConfigAbstractFactory::class,
@ -43,11 +44,8 @@ return [
], ],
ConfigAbstractFactory::class => [ ConfigAbstractFactory::class => [
NotFoundHandler::class => [ ErrorHandler\NotFoundRedirectHandler::class => [NotFoundRedirectOptions::class, 'config.router.base_path'],
TemplateRendererInterface::class, ErrorHandler\NotFoundTemplateHandler::class => [TemplateRendererInterface::class],
NotFoundRedirectOptions::class,
'config.router.base_path',
],
Options\AppOptions::class => ['config.app_options'], Options\AppOptions::class => ['config.app_options'],
Options\DeleteShortUrlsOptions::class => ['config.delete_short_urls'], 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\LoggerInterface;
use Psr\Log\NullLogger; use Psr\Log\NullLogger;
use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException;
use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Options\AppOptions; use Shlinkio\Shlink\Core\Options\AppOptions;
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface; use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
@ -72,7 +71,7 @@ abstract class AbstractTrackingAction implements MiddlewareInterface
} }
return $this->createSuccessResp($this->buildUrlToRedirectTo($url, $query, $disableTrackParam)); 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]); $this->logger->warning('An error occurred while tracking short code. {e}', ['e' => $e]);
return $this->createErrorResp($request, $handler); return $this->createErrorResp($request, $handler);
} }

View file

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

View file

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

View file

@ -2,78 +2,40 @@
declare(strict_types=1); 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\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\UriInterface; use Psr\Http\Message\UriInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface; use Psr\Http\Server\RequestHandlerInterface;
use Shlinkio\Shlink\Core\Action\RedirectAction; use Shlinkio\Shlink\Core\Action\RedirectAction;
use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions; use Shlinkio\Shlink\Core\Options\NotFoundRedirectOptions;
use Zend\Diactoros\Response; use Zend\Diactoros\Response;
use Zend\Expressive\Router\RouteResult; use Zend\Expressive\Router\RouteResult;
use Zend\Expressive\Template\TemplateRendererInterface;
use function array_shift;
use function explode;
use function Functional\contains;
use function rtrim; 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 */ /** @var NotFoundRedirectOptions */
private $redirectOptions; private $redirectOptions;
/** @var string */ /** @var string */
private $shlinkBasePath; private $shlinkBasePath;
public function __construct( public function __construct(NotFoundRedirectOptions $redirectOptions, string $shlinkBasePath)
TemplateRendererInterface $renderer, {
NotFoundRedirectOptions $redirectOptions,
string $shlinkBasePath
) {
$this->renderer = $renderer;
$this->redirectOptions = $redirectOptions; $this->redirectOptions = $redirectOptions;
$this->shlinkBasePath = $shlinkBasePath; $this->shlinkBasePath = $shlinkBasePath;
} }
/** public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
* 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 */ /** @var RouteResult $routeResult */
$routeResult = $request->getAttribute(RouteResult::class, RouteResult::fromRouteFailure(null)); $routeResult = $request->getAttribute(RouteResult::class, RouteResult::fromRouteFailure(null));
$redirectResponse = $this->createRedirectResponse($routeResult, $request->getUri()); $redirectResponse = $this->createRedirectResponse($routeResult, $request->getUri());
if ($redirectResponse !== null) {
return $redirectResponse;
}
$accepts = explode(',', $request->getHeaderLine('Accept')); return $redirectResponse ?? $handler->handle($request);
$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);
} }
private function createRedirectResponse(RouteResult $routeResult, UriInterface $uri): ?ResponseInterface 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; 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; use function sprintf;
class DeleteShortUrlException extends RuntimeException class DeleteShortUrlException extends DomainException implements ProblemDetailsExceptionInterface
{ {
/** @var int */ use CommonProblemDetailsExceptionTrait;
private $visitsThreshold;
public function __construct(int $visitsThreshold, string $message = '', int $code = 0, ?Throwable $previous = null) private const TITLE = 'Cannot delete short URL';
{ private const TYPE = 'INVALID_SHORTCODE_DELETION'; // FIXME Should be INVALID_SHORT_URL_DELETION
$this->visitsThreshold = $visitsThreshold;
parent::__construct($message, $code, $previous);
}
public static function fromVisitsThreshold(int $threshold, string $shortCode): self 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.', 'Impossible to delete short URL with short code "%s" since it has more than "%s" visits.',
$shortCode, $shortCode,
$threshold $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 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; namespace Shlinkio\Shlink\Core\Exception;
use Fig\Http\Message\StatusCodeInterface;
use Throwable; use Throwable;
use Zend\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait;
use Zend\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
use function sprintf; 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 public static function fromUrl(string $url, ?Throwable $previous = null): self
{ {
$code = $previous !== null ? $previous->getCode() : -1; $status = StatusCodeInterface::STATUS_BAD_REQUEST;
return new static(sprintf('Provided URL "%s" is not an existing and valid URL', $url), $code, $previous); $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; namespace Shlinkio\Shlink\Core\Exception;
use Fig\Http\Message\StatusCodeInterface;
use Zend\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait;
use Zend\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
use function sprintf; 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) { 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; namespace Shlinkio\Shlink\Core\Exception;
use Fig\Http\Message\StatusCodeInterface;
use Throwable; use Throwable;
use Zend\InputFilter\InputFilterInterface; 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 Functional\reduce_left;
use function is_array; use function is_array;
use function print_r; use function print_r;
@ -14,44 +18,59 @@ use function sprintf;
use const PHP_EOL; 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 */ /** @var array */
private $invalidElements; 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 public static function fromInputFilter(InputFilterInterface $inputFilter, ?Throwable $prev = null): self
{ {
return static::fromArray($inputFilter->getMessages(), $prev); 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( $status = StatusCodeInterface::STATUS_BAD_REQUEST;
sprintf( $e = new self('Provided data is not valid', $status, $prev);
'Provided data is not valid. These are the messages:%s%s%s',
$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,
self::formMessagesToString($invalidData), PHP_EOL,
PHP_EOL $this->getTraceAsString()
),
$invalidData,
-1,
$prev
); );
} }
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( return $acc . sprintf(
"\n '%s' => %s", "\n '%s' => %s",
$name, $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 * @throws Exception\DeleteShortUrlException
*/ */
public function deleteByShortCode(string $shortCode, bool $ignoreThreshold = false): void public function deleteByShortCode(string $shortCode, bool $ignoreThreshold = false): void

View file

@ -9,7 +9,7 @@ use Shlinkio\Shlink\Core\Exception;
interface DeleteShortUrlServiceInterface interface DeleteShortUrlServiceInterface
{ {
/** /**
* @throws Exception\InvalidShortCodeException * @throws Exception\ShortUrlNotFoundException
* @throws Exception\DeleteShortUrlException * @throws Exception\DeleteShortUrlException
*/ */
public function deleteByShortCode(string $shortCode, bool $ignoreThreshold = false): void; 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 Doctrine\ORM\EntityManagerInterface;
use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\InvalidShortCodeException; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
trait FindShortCodeTrait trait FindShortCodeTrait
{ {
/** /**
* @param string $shortCode * @param string $shortCode
* @return ShortUrl * @return ShortUrl
* @throws InvalidShortCodeException * @throws ShortUrlNotFoundException
*/ */
private function findByShortCode(EntityManagerInterface $em, string $shortCode): ShortUrl private function findByShortCode(EntityManagerInterface $em, string $shortCode): ShortUrl
{ {
@ -22,7 +22,7 @@ trait FindShortCodeTrait
'shortCode' => $shortCode, 'shortCode' => $shortCode,
]); ]);
if ($shortUrl === null) { if ($shortUrl === null) {
throw InvalidShortCodeException::fromNotFoundShortCode($shortCode); throw ShortUrlNotFoundException::fromNotFoundShortCode($shortCode);
} }
return $shortUrl; return $shortUrl;

View file

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

View file

@ -5,7 +5,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Service; namespace Shlinkio\Shlink\Core\Service;
use Shlinkio\Shlink\Core\Entity\ShortUrl; 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\Model\ShortUrlMeta;
use Zend\Paginator\Paginator; use Zend\Paginator\Paginator;
@ -20,12 +20,12 @@ interface ShortUrlServiceInterface
/** /**
* @param string[] $tags * @param string[] $tags
* @throws InvalidShortCodeException * @throws ShortUrlNotFoundException
*/ */
public function setTagsByShortCode(string $shortCode, array $tags = []): ShortUrl; public function setTagsByShortCode(string $shortCode, array $tags = []): ShortUrl;
/** /**
* @throws InvalidShortCodeException * @throws ShortUrlNotFoundException
*/ */
public function updateMetadataByShortCode(string $shortCode, ShortUrlMeta $shortUrlMeta): ShortUrl; 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\Common\Collections\Collection;
use Doctrine\ORM; use Doctrine\ORM;
use Shlinkio\Shlink\Core\Entity\Tag; 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\Repository\TagRepository;
use Shlinkio\Shlink\Core\Util\TagManagerTrait; use Shlinkio\Shlink\Core\Util\TagManagerTrait;
@ -35,8 +35,7 @@ class TagService implements TagServiceInterface
} }
/** /**
* @param array $tagNames * @param string[] $tagNames
* @return void
*/ */
public function deleteTags(array $tagNames): void public function deleteTags(array $tagNames): void
{ {
@ -60,23 +59,17 @@ class TagService implements TagServiceInterface
} }
/** /**
* @param string $oldName * @throws TagNotFoundException
* @param string $newName
* @return Tag
* @throws EntityDoesNotExistException
* @throws ORM\OptimisticLockException
*/ */
public function renameTag($oldName, $newName): Tag public function renameTag(string $oldName, string $newName): Tag
{ {
$criteria = ['name' => $oldName];
/** @var Tag|null $tag */ /** @var Tag|null $tag */
$tag = $this->em->getRepository(Tag::class)->findOneBy($criteria); $tag = $this->em->getRepository(Tag::class)->findOneBy(['name' => $oldName]);
if ($tag === null) { if ($tag === null) {
throw EntityDoesNotExistException::createFromEntityAndConditions(Tag::class, $criteria); throw TagNotFoundException::fromTag($oldName);
} }
$tag->rename($newName); $tag->rename($newName);
$this->em->flush(); $this->em->flush();
return $tag; return $tag;

View file

@ -6,7 +6,7 @@ namespace Shlinkio\Shlink\Core\Service\Tag;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Exception\EntityDoesNotExistException; use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
interface TagServiceInterface interface TagServiceInterface
{ {
@ -17,23 +17,17 @@ interface TagServiceInterface
/** /**
* @param string[] $tagNames * @param string[] $tagNames
* @return void
*/ */
public function deleteTags(array $tagNames): void; public function deleteTags(array $tagNames): void;
/** /**
* Provided a list of tag names, creates all that do not exist yet
*
* @param string[] $tagNames * @param string[] $tagNames
* @return Collection|Tag[] * @return Collection|Tag[]
*/ */
public function createTags(array $tagNames): Collection; public function createTags(array $tagNames): Collection;
/** /**
* @param string $oldName * @throws TagNotFoundException
* @param string $newName
* @return Tag
* @throws EntityDoesNotExistException
*/ */
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 Psr\Http\Message\UriInterface;
use Shlinkio\Shlink\Core\Domain\Resolver\PersistenceDomainResolver; use Shlinkio\Shlink\Core\Domain\Resolver\PersistenceDomainResolver;
use Shlinkio\Shlink\Core\Entity\ShortUrl; 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\InvalidUrlException;
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository; use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
@ -130,8 +129,8 @@ class UrlShortener implements UrlShortenerInterface
} }
/** /**
* @throws InvalidShortCodeException * @throws ShortUrlNotFoundException
* @throws EntityDoesNotExistException * @fixme Move this method to a different service
*/ */
public function shortCodeToUrl(string $shortCode, ?string $domain = null): ShortUrl public function shortCodeToUrl(string $shortCode, ?string $domain = null): ShortUrl
{ {
@ -139,10 +138,7 @@ class UrlShortener implements UrlShortenerInterface
$shortUrlRepo = $this->em->getRepository(ShortUrl::class); $shortUrlRepo = $this->em->getRepository(ShortUrl::class);
$shortUrl = $shortUrlRepo->findOneByShortCode($shortCode, $domain); $shortUrl = $shortUrlRepo->findOneByShortCode($shortCode, $domain);
if ($shortUrl === null) { if ($shortUrl === null) {
throw EntityDoesNotExistException::createFromEntityAndConditions(ShortUrl::class, [ throw ShortUrlNotFoundException::fromNotFoundShortCode($shortCode, $domain);
'shortCode' => $shortCode,
'domain' => $domain,
]);
} }
return $shortUrl; return $shortUrl;

View file

@ -6,11 +6,9 @@ namespace Shlinkio\Shlink\Core\Service;
use Psr\Http\Message\UriInterface; use Psr\Http\Message\UriInterface;
use Shlinkio\Shlink\Core\Entity\ShortUrl; 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\InvalidUrlException;
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\Exception\RuntimeException; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
interface UrlShortenerInterface interface UrlShortenerInterface
@ -19,13 +17,11 @@ interface UrlShortenerInterface
* @param string[] $tags * @param string[] $tags
* @throws NonUniqueSlugException * @throws NonUniqueSlugException
* @throws InvalidUrlException * @throws InvalidUrlException
* @throws RuntimeException
*/ */
public function urlToShortCode(UriInterface $url, array $tags, ShortUrlMeta $meta): ShortUrl; public function urlToShortCode(UriInterface $url, array $tags, ShortUrlMeta $meta): ShortUrl;
/** /**
* @throws InvalidShortCodeException * @throws ShortUrlNotFoundException
* @throws EntityDoesNotExistException
*/ */
public function shortCodeToUrl(string $shortCode, ?string $domain = null): ShortUrl; 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\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\EventDispatcher\ShortUrlVisited; 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\Visitor;
use Shlinkio\Shlink\Core\Model\VisitsParams; use Shlinkio\Shlink\Core\Model\VisitsParams;
use Shlinkio\Shlink\Core\Paginator\Adapter\VisitsPaginatorAdapter; use Shlinkio\Shlink\Core\Paginator\Adapter\VisitsPaginatorAdapter;
use Shlinkio\Shlink\Core\Repository\VisitRepository; use Shlinkio\Shlink\Core\Repository\VisitRepository;
use Zend\Paginator\Paginator; use Zend\Paginator\Paginator;
use function sprintf;
class VisitsTracker implements VisitsTrackerInterface class VisitsTracker implements VisitsTrackerInterface
{ {
/** @var ORM\EntityManagerInterface */ /** @var ORM\EntityManagerInterface */
@ -53,14 +51,14 @@ class VisitsTracker implements VisitsTrackerInterface
* Returns the visits on certain short code * Returns the visits on certain short code
* *
* @return Visit[]|Paginator * @return Visit[]|Paginator
* @throws InvalidArgumentException * @throws ShortUrlNotFoundException
*/ */
public function info(string $shortCode, VisitsParams $params): Paginator public function info(string $shortCode, VisitsParams $params): Paginator
{ {
/** @var ORM\EntityRepository $repo */ /** @var ORM\EntityRepository $repo */
$repo = $this->em->getRepository(ShortUrl::class); $repo = $this->em->getRepository(ShortUrl::class);
if ($repo->count(['shortCode' => $shortCode]) < 1) { if ($repo->count(['shortCode' => $shortCode]) < 1) {
throw new InvalidArgumentException(sprintf('Short code "%s" not found', $shortCode)); throw ShortUrlNotFoundException::fromNotFoundShortCode($shortCode);
} }
/** @var VisitRepository $repo */ /** @var VisitRepository $repo */

View file

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

View file

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

View file

@ -10,7 +10,7 @@ use Prophecy\Prophecy\ObjectProphecy;
use Psr\Http\Server\RequestHandlerInterface; use Psr\Http\Server\RequestHandlerInterface;
use Shlinkio\Shlink\Core\Action\RedirectAction; use Shlinkio\Shlink\Core\Action\RedirectAction;
use Shlinkio\Shlink\Core\Entity\ShortUrl; 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\Options;
use Shlinkio\Shlink\Core\Service\UrlShortener; use Shlinkio\Shlink\Core\Service\UrlShortener;
use Shlinkio\Shlink\Core\Service\VisitsTracker; use Shlinkio\Shlink\Core\Service\VisitsTracker;
@ -76,7 +76,7 @@ class RedirectActionTest extends TestCase
public function nextMiddlewareIsInvokedIfLongUrlIsNotFound(): void public function nextMiddlewareIsInvokedIfLongUrlIsNotFound(): void
{ {
$shortCode = 'abc123'; $shortCode = 'abc123';
$this->urlShortener->shortCodeToUrl($shortCode, '')->willThrow(EntityDoesNotExistException::class) $this->urlShortener->shortCodeToUrl($shortCode, '')->willThrow(ShortUrlNotFoundException::class)
->shouldBeCalledOnce(); ->shouldBeCalledOnce();
$this->visitTracker->track(Argument::cetera())->shouldNotBeCalled(); $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; namespace ShlinkioTest\Shlink\Core\Exception;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Common\Util\StringUtilsTrait;
use Shlinkio\Shlink\Core\Exception\DeleteShortUrlException; use Shlinkio\Shlink\Core\Exception\DeleteShortUrlException;
use function Functional\map; use function Functional\map;
use function range; use function range;
use function Shlinkio\Shlink\Core\generateRandomShortCode;
use function sprintf; use function sprintf;
class DeleteShortUrlExceptionTest extends TestCase class DeleteShortUrlExceptionTest extends TestCase
{ {
use StringUtilsTrait;
/** /**
* @test * @test
* @dataProvider provideThresholds * @dataProvider provideThresholds
@ -29,29 +27,12 @@ class DeleteShortUrlExceptionTest extends TestCase
$this->assertEquals($threshold, $e->getVisitsThreshold()); $this->assertEquals($threshold, $e->getVisitsThreshold());
$this->assertEquals($expectedMessage, $e->getMessage()); $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 public function provideThresholds(): array
{ {
return map(range(5, 50, 5), function (int $number) { return map(range(5, 50, 5), function (int $number) {
$shortCode = $this->generateRandomString(6); return [$number, $shortCode = generateRandomShortCode(6), sprintf(
return [$number, $shortCode, sprintf(
'Impossible to delete short URL with short code "%s" since it has more than "%s" visits.', 'Impossible to delete short URL with short code "%s" since it has more than "%s" visits.',
$shortCode, $shortCode,
$number $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; namespace ShlinkioTest\Shlink\Core\Exception;
use Exception; use Exception;
use Fig\Http\Message\StatusCodeInterface;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Throwable; use Throwable;
@ -19,8 +20,8 @@ class InvalidUrlExceptionTest extends TestCase
{ {
$e = InvalidUrlException::fromUrl('http://the_url.com', $prev); $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('Provided URL http://the_url.com is invalid. Try with a different one.', $e->getMessage());
$this->assertEquals($prev !== null ? $prev->getCode() : -1, $e->getCode()); $this->assertEquals(StatusCodeInterface::STATUS_BAD_REQUEST, $e->getCode());
$this->assertEquals($prev, $e->getPrevious()); $this->assertEquals($prev, $e->getPrevious());
} }

View file

@ -22,12 +22,12 @@ class NonUniqueSlugExceptionTest extends TestCase
public function provideMessages(): iterable public function provideMessages(): iterable
{ {
yield 'without domain' => [ yield 'without domain' => [
'Provided slug "foo" is not unique.', 'Provided slug "foo" is already in use.',
'foo', 'foo',
null, null,
]; ];
yield 'with domain' => [ yield 'with domain' => [
'Provided slug "baz" is not unique for domain "bar".', 'Provided slug "baz" is already in use for domain "bar".',
'baz', 'baz',
'bar', '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; namespace ShlinkioTest\Shlink\Core\Exception;
use Fig\Http\Message\StatusCodeInterface;
use LogicException; use LogicException;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use RuntimeException; use RuntimeException;
@ -11,39 +12,11 @@ use Shlinkio\Shlink\Core\Exception\ValidationException;
use Throwable; use Throwable;
use Zend\InputFilter\InputFilterInterface; use Zend\InputFilter\InputFilterInterface;
use function array_keys;
use function print_r; use function print_r;
use function random_int;
class ValidationExceptionTest extends TestCase 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 * @test
* @dataProvider provideExceptions * @dataProvider provideExceptions
@ -55,12 +28,9 @@ class ValidationExceptionTest extends TestCase
'something' => ['baz', 'foo'], 'something' => ['baz', 'foo'],
]; ];
$barValue = print_r(['baz', 'foo'], true); $barValue = print_r(['baz', 'foo'], true);
$expectedMessage = <<<EOT $expectedStringRepresentation = <<<EOT
Provided data is not valid. These are the messages:
'foo' => bar 'foo' => bar
'something' => {$barValue} 'something' => {$barValue}
EOT; EOT;
$inputFilter = $this->prophesize(InputFilterInterface::class); $inputFilter = $this->prophesize(InputFilterInterface::class);
@ -69,9 +39,11 @@ EOT;
$e = ValidationException::fromInputFilter($inputFilter->reveal()); $e = ValidationException::fromInputFilter($inputFilter->reveal());
$this->assertEquals($invalidData, $e->getInvalidElements()); $this->assertEquals($invalidData, $e->getInvalidElements());
$this->assertEquals($expectedMessage, $e->getMessage()); $this->assertEquals(['invalidElements' => array_keys($invalidData)], $e->getAdditionalData());
$this->assertEquals(-1, $e->getCode()); $this->assertEquals('Provided data is not valid', $e->getMessage());
$this->assertEquals(StatusCodeInterface::STATUS_BAD_REQUEST, $e->getCode());
$this->assertEquals($prev, $e->getPrevious()); $this->assertEquals($prev, $e->getPrevious());
$this->assertStringContainsString($expectedStringRepresentation, (string) $e);
$getMessages->shouldHaveBeenCalledOnce(); $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 Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Tag; 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\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository; use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
use Shlinkio\Shlink\Core\Service\ShortUrlService; use Shlinkio\Shlink\Core\Service\ShortUrlService;
@ -62,7 +62,7 @@ class ShortUrlServiceTest extends TestCase
->shouldBeCalledOnce(); ->shouldBeCalledOnce();
$this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal()); $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
$this->expectException(InvalidShortCodeException::class); $this->expectException(ShortUrlNotFoundException::class);
$this->service->setTagsByShortCode($shortCode); $this->service->setTagsByShortCode($shortCode);
} }

View file

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

View file

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

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Rest; namespace Shlinkio\Shlink\Rest;
use Doctrine\DBAL\Connection;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Options\AppOptions; use Shlinkio\Shlink\Core\Options\AppOptions;
use Shlinkio\Shlink\Core\Service; use Shlinkio\Shlink\Core\Service;
@ -20,15 +21,15 @@ return [
ApiKeyService::class => ConfigAbstractFactory::class, ApiKeyService::class => ConfigAbstractFactory::class,
Action\AuthenticateAction::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\CreateShortUrlAction::class => ConfigAbstractFactory::class,
Action\ShortUrl\SingleStepCreateShortUrlAction::class => ConfigAbstractFactory::class, Action\ShortUrl\SingleStepCreateShortUrlAction::class => ConfigAbstractFactory::class,
Action\ShortUrl\EditShortUrlAction::class => ConfigAbstractFactory::class, Action\ShortUrl\EditShortUrlAction::class => ConfigAbstractFactory::class,
Action\ShortUrl\DeleteShortUrlAction::class => ConfigAbstractFactory::class, Action\ShortUrl\DeleteShortUrlAction::class => ConfigAbstractFactory::class,
Action\ShortUrl\ResolveShortUrlAction::class => ConfigAbstractFactory::class, Action\ShortUrl\ResolveShortUrlAction::class => ConfigAbstractFactory::class,
Action\Visit\GetVisitsAction::class => ConfigAbstractFactory::class,
Action\ShortUrl\ListShortUrlsAction::class => ConfigAbstractFactory::class, Action\ShortUrl\ListShortUrlsAction::class => ConfigAbstractFactory::class,
Action\ShortUrl\EditShortUrlTagsAction::class => ConfigAbstractFactory::class, Action\ShortUrl\EditShortUrlTagsAction::class => ConfigAbstractFactory::class,
Action\Visit\GetVisitsAction::class => ConfigAbstractFactory::class,
Action\Tag\ListTagsAction::class => ConfigAbstractFactory::class, Action\Tag\ListTagsAction::class => ConfigAbstractFactory::class,
Action\Tag\DeleteTagsAction::class => ConfigAbstractFactory::class, Action\Tag\DeleteTagsAction::class => ConfigAbstractFactory::class,
Action\Tag\CreateTagsAction::class => ConfigAbstractFactory::class, Action\Tag\CreateTagsAction::class => ConfigAbstractFactory::class,
@ -38,6 +39,7 @@ return [
Middleware\BodyParserMiddleware::class => InvokableFactory::class, Middleware\BodyParserMiddleware::class => InvokableFactory::class,
Middleware\CrossDomainMiddleware::class => InvokableFactory::class, Middleware\CrossDomainMiddleware::class => InvokableFactory::class,
Middleware\PathVersionMiddleware::class => InvokableFactory::class, Middleware\PathVersionMiddleware::class => InvokableFactory::class,
Middleware\BackwardsCompatibleProblemDetailsMiddleware::class => ConfigAbstractFactory::class,
Middleware\ShortUrl\CreateShortUrlContentNegotiationMiddleware::class => InvokableFactory::class, Middleware\ShortUrl\CreateShortUrlContentNegotiationMiddleware::class => InvokableFactory::class,
Middleware\ShortUrl\ShortCodePathMiddleware::class => InvokableFactory::class, Middleware\ShortUrl\ShortCodePathMiddleware::class => InvokableFactory::class,
], ],
@ -48,6 +50,7 @@ return [
ApiKeyService::class => ['em'], ApiKeyService::class => ['em'],
Action\AuthenticateAction::class => [ApiKeyService::class, Authentication\JWTService::class, 'Logger_Shlink'], Action\AuthenticateAction::class => [ApiKeyService::class, Authentication\JWTService::class, 'Logger_Shlink'],
Action\HealthAction::class => [Connection::class, AppOptions::class, 'Logger_Shlink'],
Action\ShortUrl\CreateShortUrlAction::class => [ Action\ShortUrl\CreateShortUrlAction::class => [
Service\UrlShortener::class, Service\UrlShortener::class,
'config.url_shortener.domain', 'config.url_shortener.domain',
@ -73,6 +76,11 @@ return [
Action\Tag\DeleteTagsAction::class => [Service\Tag\TagService::class, LoggerInterface::class], Action\Tag\DeleteTagsAction::class => [Service\Tag\TagService::class, LoggerInterface::class],
Action\Tag\CreateTagsAction::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], 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\Authentication\JWTServiceInterface;
use Shlinkio\Shlink\Rest\Service\ApiKeyService; use Shlinkio\Shlink\Rest\Service\ApiKeyService;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface; use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use Shlinkio\Shlink\Rest\Util\RestUtils;
use Zend\Diactoros\Response\JsonResponse; use Zend\Diactoros\Response\JsonResponse;
/** @deprecated */ /** @deprecated */
@ -44,7 +43,7 @@ class AuthenticateAction extends AbstractRestAction
$authData = $request->getParsedBody(); $authData = $request->getParsedBody();
if (! isset($authData['apiKey'])) { if (! isset($authData['apiKey'])) {
return new JsonResponse([ 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.', 'message' => 'You have to provide a valid API key under the "apiKey" param name.',
], self::STATUS_BAD_REQUEST); ], self::STATUS_BAD_REQUEST);
} }
@ -53,7 +52,7 @@ class AuthenticateAction extends AbstractRestAction
$apiKey = $this->apiKeyService->getByKey($authData['apiKey']); $apiKey = $this->apiKeyService->getByKey($authData['apiKey']);
if ($apiKey === null || ! $apiKey->isValid()) { if ($apiKey === null || ! $apiKey->isValid()) {
return new JsonResponse([ return new JsonResponse([
'error' => RestUtils::INVALID_API_KEY_ERROR, 'error' => 'INVALID_API_KEY',
'message' => 'Provided API key does not exist or is invalid.', 'message' => 'Provided API key does not exist or is invalid.',
], self::STATUS_UNAUTHORIZED); ], self::STATUS_UNAUTHORIZED);
} }

View file

@ -15,8 +15,8 @@ use Zend\Diactoros\Response\JsonResponse;
class HealthAction extends AbstractRestAction class HealthAction extends AbstractRestAction
{ {
private const HEALTH_CONTENT_TYPE = 'application/health+json'; private const HEALTH_CONTENT_TYPE = 'application/health+json';
private const PASS_STATUS = 'pass'; private const STATUS_PASS = 'pass';
private const FAIL_STATUS = 'fail'; private const STATUS_FAIL = 'fail';
protected const ROUTE_PATH = '/health'; protected const ROUTE_PATH = '/health';
protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET]; 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; $statusCode = $connected ? self::STATUS_OK : self::STATUS_SERVICE_UNAVAILABLE;
return new JsonResponse([ return new JsonResponse([
'status' => $connected ? self::PASS_STATUS : self::FAIL_STATUS, 'status' => $connected ? self::STATUS_PASS : self::STATUS_FAIL,
'version' => $this->options->getVersion(), 'version' => $this->options->getVersion(),
'links' => [ 'links' => [
'about' => 'https://shlink.io', '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\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Exception\InvalidArgumentException; use Shlinkio\Shlink\Core\Exception\ValidationException;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\Model\CreateShortUrlData; use Shlinkio\Shlink\Core\Model\CreateShortUrlData;
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface; use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer; use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction; use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
use Shlinkio\Shlink\Rest\Util\RestUtils;
use Throwable;
use Zend\Diactoros\Response\JsonResponse; use Zend\Diactoros\Response\JsonResponse;
use function sprintf;
abstract class AbstractCreateShortUrlAction extends AbstractRestAction abstract class AbstractCreateShortUrlAction extends AbstractRestAction
{ {
/** @var UrlShortenerInterface */ /** @var UrlShortenerInterface */
@ -37,56 +31,23 @@ abstract class AbstractCreateShortUrlAction extends AbstractRestAction
$this->domainConfig = $domainConfig; $this->domainConfig = $domainConfig;
} }
/**
* @param Request $request
* @return Response
*/
public function handle(Request $request): Response public function handle(Request $request): Response
{ {
try {
$shortUrlData = $this->buildShortUrlData($request); $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);
}
$longUrl = $shortUrlData->getLongUrl(); $longUrl = $shortUrlData->getLongUrl();
$tags = $shortUrlData->getTags();
$shortUrlMeta = $shortUrlData->getMeta(); $shortUrlMeta = $shortUrlData->getMeta();
try { $shortUrl = $this->urlShortener->urlToShortCode($longUrl, $tags, $shortUrlMeta);
$shortUrl = $this->urlShortener->urlToShortCode($longUrl, $shortUrlData->getTags(), $shortUrlMeta);
$transformer = new ShortUrlDataTransformer($this->domainConfig); $transformer = new ShortUrlDataTransformer($this->domainConfig);
return new JsonResponse($transformer->transform($shortUrl)); 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);
}
} }
/** /**
* @param Request $request * @param Request $request
* @return CreateShortUrlData * @return CreateShortUrlData
* @throws InvalidArgumentException * @throws ValidationException
*/ */
abstract protected function buildShortUrlData(Request $request): CreateShortUrlData; abstract protected function buildShortUrlData(Request $request): CreateShortUrlData;
} }

View file

@ -6,7 +6,6 @@ namespace Shlinkio\Shlink\Rest\Action\ShortUrl;
use Cake\Chronos\Chronos; use Cake\Chronos\Chronos;
use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Message\ServerRequestInterface as Request;
use Shlinkio\Shlink\Core\Exception\InvalidArgumentException;
use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Exception\ValidationException;
use Shlinkio\Shlink\Core\Model\CreateShortUrlData; use Shlinkio\Shlink\Core\Model\CreateShortUrlData;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
@ -20,17 +19,17 @@ class CreateShortUrlAction extends AbstractCreateShortUrlAction
/** /**
* @param Request $request * @param Request $request
* @return CreateShortUrlData * @return CreateShortUrlData
* @throws InvalidArgumentException * @throws ValidationException
* @throws \InvalidArgumentException
*/ */
protected function buildShortUrlData(Request $request): CreateShortUrlData protected function buildShortUrlData(Request $request): CreateShortUrlData
{ {
$postData = (array) $request->getParsedBody(); $postData = (array) $request->getParsedBody();
if (! isset($postData['longUrl'])) { 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( $meta = ShortUrlMeta::createFromParams(
$this->getOptionalDate($postData, 'validSince'), $this->getOptionalDate($postData, 'validSince'),
$this->getOptionalDate($postData, 'validUntil'), $this->getOptionalDate($postData, 'validUntil'),
@ -41,9 +40,6 @@ class CreateShortUrlAction extends AbstractCreateShortUrlAction
); );
return new CreateShortUrlData(new Uri($postData['longUrl']), (array) ($postData['tags'] ?? []), $meta); 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);
}
} }
private function getOptionalDate(array $postData, string $fieldName): ?Chronos 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\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Exception;
use Shlinkio\Shlink\Core\Service\ShortUrl\DeleteShortUrlServiceInterface; use Shlinkio\Shlink\Core\Service\ShortUrl\DeleteShortUrlServiceInterface;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction; use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
use Shlinkio\Shlink\Rest\Util\RestUtils;
use Zend\Diactoros\Response\EmptyResponse; use Zend\Diactoros\Response\EmptyResponse;
use Zend\Diactoros\Response\JsonResponse;
use function sprintf;
class DeleteShortUrlAction extends AbstractRestAction class DeleteShortUrlAction extends AbstractRestAction
{ {
@ -30,34 +25,10 @@ class DeleteShortUrlAction extends AbstractRestAction
$this->deleteShortUrlService = $deleteShortUrlService; $this->deleteShortUrlService = $deleteShortUrlService;
} }
/**
* Handle the request and return a response.
*/
public function handle(ServerRequestInterface $request): ResponseInterface public function handle(ServerRequestInterface $request): ResponseInterface
{ {
$shortCode = $request->getAttribute('shortCode', ''); $shortCode = $request->getAttribute('shortCode', '');
try {
$this->deleteShortUrlService->deleteByShortCode($shortCode); $this->deleteShortUrlService->deleteByShortCode($shortCode);
return new EmptyResponse(); 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);
}
} }
} }

View file

@ -7,15 +7,10 @@ namespace Shlinkio\Shlink\Rest\Action\ShortUrl;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Exception;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface; use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction; use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
use Shlinkio\Shlink\Rest\Util\RestUtils;
use Zend\Diactoros\Response\EmptyResponse; use Zend\Diactoros\Response\EmptyResponse;
use Zend\Diactoros\Response\JsonResponse;
use function sprintf;
class EditShortUrlAction extends AbstractRestAction class EditShortUrlAction extends AbstractRestAction
{ {
@ -31,38 +26,12 @@ class EditShortUrlAction extends AbstractRestAction
$this->shortUrlService = $shortUrlService; $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 public function handle(ServerRequestInterface $request): ResponseInterface
{ {
$postData = (array) $request->getParsedBody(); $postData = (array) $request->getParsedBody();
$shortCode = $request->getAttribute('shortCode', ''); $shortCode = $request->getAttribute('shortCode', '');
try { $this->shortUrlService->updateMetadataByShortCode($shortCode, ShortUrlMeta::createFromRawData($postData));
$this->shortUrlService->updateMetadataByShortCode(
$shortCode,
ShortUrlMeta::createFromRawData($postData)
);
return new EmptyResponse(); 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);
}
} }
} }

View file

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

View file

@ -4,7 +4,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Rest\Action\ShortUrl; namespace Shlinkio\Shlink\Rest\Action\ShortUrl;
use Exception; use InvalidArgumentException;
use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Log\LoggerInterface; 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\Service\ShortUrlServiceInterface;
use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer; use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction; use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
use Shlinkio\Shlink\Rest\Util\RestUtils;
use Zend\Diactoros\Response\JsonResponse; use Zend\Diactoros\Response\JsonResponse;
class ListShortUrlsAction extends AbstractRestAction class ListShortUrlsAction extends AbstractRestAction
@ -40,23 +39,15 @@ class ListShortUrlsAction extends AbstractRestAction
/** /**
* @param Request $request * @param Request $request
* @return Response * @return Response
* @throws \InvalidArgumentException * @throws InvalidArgumentException
*/ */
public function handle(Request $request): Response public function handle(Request $request): Response
{ {
try {
$params = $this->queryToListParams($request->getQueryParams()); $params = $this->queryToListParams($request->getQueryParams());
$shortUrls = $this->shortUrlService->listShortUrls(...$params); $shortUrls = $this->shortUrlService->listShortUrls(...$params);
return new JsonResponse(['shortUrls' => $this->serializePaginator($shortUrls, new ShortUrlDataTransformer( return new JsonResponse(['shortUrls' => $this->serializePaginator($shortUrls, new ShortUrlDataTransformer(
$this->domainConfig $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);
}
} }
/** /**

View file

@ -4,20 +4,15 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Rest\Action\ShortUrl; namespace Shlinkio\Shlink\Rest\Action\ShortUrl;
use Exception; use InvalidArgumentException;
use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Log\LoggerInterface; 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\Service\UrlShortenerInterface;
use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer; use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction; use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
use Shlinkio\Shlink\Rest\Util\RestUtils;
use Zend\Diactoros\Response\JsonResponse; use Zend\Diactoros\Response\JsonResponse;
use function sprintf;
class ResolveShortUrlAction extends AbstractRestAction class ResolveShortUrlAction extends AbstractRestAction
{ {
protected const ROUTE_PATH = '/short-urls/{shortCode}'; protected const ROUTE_PATH = '/short-urls/{shortCode}';
@ -41,7 +36,7 @@ class ResolveShortUrlAction extends AbstractRestAction
/** /**
* @param Request $request * @param Request $request
* @return Response * @return Response
* @throws \InvalidArgumentException * @throws InvalidArgumentException
*/ */
public function handle(Request $request): Response public function handle(Request $request): Response
{ {
@ -49,27 +44,7 @@ class ResolveShortUrlAction extends AbstractRestAction
$domain = $request->getQueryParams()['domain'] ?? null; $domain = $request->getQueryParams()['domain'] ?? null;
$transformer = new ShortUrlDataTransformer($this->domainConfig); $transformer = new ShortUrlDataTransformer($this->domainConfig);
try {
$url = $this->urlShortener->shortCodeToUrl($shortCode, $domain); $url = $this->urlShortener->shortCodeToUrl($shortCode, $domain);
return new JsonResponse($transformer->transform($url)); 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);
}
} }
} }

View file

@ -6,7 +6,7 @@ namespace Shlinkio\Shlink\Rest\Action\ShortUrl;
use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Log\LoggerInterface; 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\Model\CreateShortUrlData;
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface; use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface; use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
@ -33,19 +33,22 @@ class SingleStepCreateShortUrlAction extends AbstractCreateShortUrlAction
/** /**
* @param Request $request * @param Request $request
* @return CreateShortUrlData * @return CreateShortUrlData
* @throws \InvalidArgumentException * @throws ValidationException
* @throws InvalidArgumentException
*/ */
protected function buildShortUrlData(Request $request): CreateShortUrlData protected function buildShortUrlData(Request $request): CreateShortUrlData
{ {
$query = $request->getQueryParams(); $query = $request->getQueryParams();
if (! $this->apiKeyService->check($query['apiKey'] ?? '')) { 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'])) { 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'])); 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\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface; 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\Core\Service\Tag\TagServiceInterface;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction; use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
use Shlinkio\Shlink\Rest\Util\RestUtils;
use Zend\Diactoros\Response\EmptyResponse; use Zend\Diactoros\Response\EmptyResponse;
use Zend\Diactoros\Response\JsonResponse;
use function sprintf;
class UpdateTagAction extends AbstractRestAction class UpdateTagAction extends AbstractRestAction
{ {
@ -43,21 +39,13 @@ class UpdateTagAction extends AbstractRestAction
{ {
$body = $request->getParsedBody(); $body = $request->getParsedBody();
if (! isset($body['oldName'], $body['newName'])) { if (! isset($body['oldName'], $body['newName'])) {
return new JsonResponse([ throw ValidationException::fromArray([
'error' => RestUtils::INVALID_ARGUMENT_ERROR, 'oldName' => 'oldName is required',
'message' => 'newName' => 'newName is required',
'You have to provide both \'oldName\' and \'newName\' params in order to properly rename the tag', ]);
], self::STATUS_BAD_REQUEST);
} }
try {
$this->tagService->renameTag($body['oldName'], $body['newName']); $this->tagService->renameTag($body['oldName'], $body['newName']);
return new EmptyResponse(); 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);
}
} }
} }

View file

@ -7,16 +7,12 @@ namespace Shlinkio\Shlink\Rest\Action\Visit;
use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Common\Exception\InvalidArgumentException;
use Shlinkio\Shlink\Common\Paginator\Util\PaginatorUtilsTrait; use Shlinkio\Shlink\Common\Paginator\Util\PaginatorUtilsTrait;
use Shlinkio\Shlink\Core\Model\VisitsParams; use Shlinkio\Shlink\Core\Model\VisitsParams;
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface; use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction; use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
use Shlinkio\Shlink\Rest\Util\RestUtils;
use Zend\Diactoros\Response\JsonResponse; use Zend\Diactoros\Response\JsonResponse;
use function sprintf;
class GetVisitsAction extends AbstractRestAction class GetVisitsAction extends AbstractRestAction
{ {
use PaginatorUtilsTrait; use PaginatorUtilsTrait;
@ -33,27 +29,13 @@ class GetVisitsAction extends AbstractRestAction
$this->visitsTracker = $visitsTracker; $this->visitsTracker = $visitsTracker;
} }
/**
* @param Request $request
* @return Response
* @throws \InvalidArgumentException
*/
public function handle(Request $request): Response public function handle(Request $request): Response
{ {
$shortCode = $request->getAttribute('shortCode'); $shortCode = $request->getAttribute('shortCode');
try {
$visits = $this->visitsTracker->info($shortCode, VisitsParams::fromRawData($request->getQueryParams())); $visits = $this->visitsTracker->info($shortCode, VisitsParams::fromRawData($request->getQueryParams()));
return new JsonResponse([ return new JsonResponse([
'visits' => $this->serializePaginator($visits), '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);
}
} }
} }

View file

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

View file

@ -8,7 +8,6 @@ use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Rest\Exception\VerifyAuthenticationException; use Shlinkio\Shlink\Rest\Exception\VerifyAuthenticationException;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface; use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
use Shlinkio\Shlink\Rest\Util\RestUtils;
class ApiKeyHeaderPlugin implements AuthenticationPluginInterface class ApiKeyHeaderPlugin implements AuthenticationPluginInterface
{ {
@ -28,14 +27,9 @@ class ApiKeyHeaderPlugin implements AuthenticationPluginInterface
public function verify(ServerRequestInterface $request): void public function verify(ServerRequestInterface $request): void
{ {
$apiKey = $request->getHeaderLine(self::HEADER_NAME); $apiKey = $request->getHeaderLine(self::HEADER_NAME);
if ($this->apiKeyService->check($apiKey)) { if (! $this->apiKeyService->check($apiKey)) {
return; 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 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 Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Rest\Authentication\JWTServiceInterface; use Shlinkio\Shlink\Rest\Authentication\JWTServiceInterface;
use Shlinkio\Shlink\Rest\Exception\VerifyAuthenticationException; use Shlinkio\Shlink\Rest\Exception\VerifyAuthenticationException;
use Shlinkio\Shlink\Rest\Util\RestUtils;
use Throwable; use Throwable;
use function count; use function count;
@ -16,6 +15,7 @@ use function explode;
use function sprintf; use function sprintf;
use function strtolower; use function strtolower;
/** @deprecated */
class AuthorizationHeaderPlugin implements AuthenticationPluginInterface class AuthorizationHeaderPlugin implements AuthenticationPluginInterface
{ {
public const HEADER_NAME = 'Authorization'; public const HEADER_NAME = 'Authorization';
@ -37,19 +37,13 @@ class AuthorizationHeaderPlugin implements AuthenticationPluginInterface
$authToken = $request->getHeaderLine(self::HEADER_NAME); $authToken = $request->getHeaderLine(self::HEADER_NAME);
$authTokenParts = explode(' ', $authToken); $authTokenParts = explode(' ', $authToken);
if (count($authTokenParts) === 1) { if (count($authTokenParts) === 1) {
throw VerifyAuthenticationException::withError( throw VerifyAuthenticationException::forMissingAuthType();
RestUtils::INVALID_AUTHORIZATION_ERROR,
sprintf('You need to provide the Bearer type in the %s header.', self::HEADER_NAME)
);
} }
// Make sure the authorization type is Bearer // Make sure the authorization type is Bearer
[$authType, $jwt] = $authTokenParts; [$authType, $jwt] = $authTokenParts;
if (strtolower($authType) !== 'bearer') { if (strtolower($authType) !== 'bearer') {
throw VerifyAuthenticationException::withError( throw VerifyAuthenticationException::forInvalidAuthType($authType);
RestUtils::INVALID_AUTHORIZATION_ERROR,
sprintf('Provided authorization type %s is not supported. Use Bearer instead.', $authType)
);
} }
try { try {
@ -57,21 +51,13 @@ class AuthorizationHeaderPlugin implements AuthenticationPluginInterface
throw $this->createInvalidTokenError(); throw $this->createInvalidTokenError();
} }
} catch (Throwable $e) { } catch (Throwable $e) {
throw $this->createInvalidTokenError($e); throw $this->createInvalidTokenError();
} }
} }
private function createInvalidTokenError(?Throwable $prev = null): VerifyAuthenticationException private function createInvalidTokenError(): VerifyAuthenticationException
{ {
return VerifyAuthenticationException::withError( return VerifyAuthenticationException::forInvalidAuthToken();
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
);
} }
public function update(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface public function update(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface

View file

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

View file

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

View file

@ -11,7 +11,7 @@ use function sprintf;
class ConfigProvider class ConfigProvider
{ {
private const ROUTES_PREFIX = '/rest/v{version:1}'; private const ROUTES_PREFIX = '/rest/v{version:1|2}';
public function __invoke() 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; use Throwable;
/** @deprecated */
class AuthenticationException extends RuntimeException class AuthenticationException extends RuntimeException
{ {
public static function expiredJWT(?Throwable $prev = null): self 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; 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; use function sprintf;
class VerifyAuthenticationException extends RuntimeException class VerifyAuthenticationException extends RuntimeException implements ProblemDetailsExceptionInterface
{ {
/** @var string */ use CommonProblemDetailsExceptionTrait;
private $errorCode;
/** @var string */
private $publicMessage;
public function __construct( public static function forInvalidApiKey(): self
string $errorCode, {
string $publicMessage, $e = new self('Provided API key does not exist or is invalid.');
string $message = '',
int $code = 0, $e->detail = $e->getMessage();
?Throwable $previous = null $e->title = 'Invalid API key';
) { $e->type = 'INVALID_API_KEY';
parent::__construct($message, $code, $previous); $e->status = StatusCodeInterface::STATUS_UNAUTHORIZED;
$this->errorCode = $errorCode;
$this->publicMessage = $publicMessage; return $e;
} }
public static function withError(string $errorCode, string $publicMessage, ?Throwable $prev = null): self /** @deprecated */
public static function forInvalidAuthToken(): self
{ {
return new self( $e = new self(
$errorCode, 'Missing or invalid auth token provided. Perform a new authentication request and send provided '
$publicMessage, . 'token on every new request on the Authorization header'
sprintf('Authentication verification failed with the public message "%s"', $publicMessage),
0,
$prev
); );
$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\RequestMethodInterface;
use Fig\Http\Message\StatusCodeInterface; use Fig\Http\Message\StatusCodeInterface;
use Psr\Container\ContainerExceptionInterface;
use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface; 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\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 Zend\Expressive\Router\RouteResult;
use function Functional\contains; use function Functional\contains;
use function implode;
use function sprintf;
class AuthenticationMiddleware implements MiddlewareInterface, StatusCodeInterface, RequestMethodInterface class AuthenticationMiddleware implements MiddlewareInterface, StatusCodeInterface, RequestMethodInterface
{ {
/** @var LoggerInterface */
private $logger;
/** @var array */ /** @var array */
private $routesWhitelist; private $routesWhitelist;
/** @var RequestToHttpAuthPluginInterface */ /** @var RequestToHttpAuthPluginInterface */
private $requestToAuthPlugin; private $requestToAuthPlugin;
public function __construct( public function __construct(RequestToHttpAuthPluginInterface $requestToAuthPlugin, array $routesWhitelist)
RequestToHttpAuthPluginInterface $requestToAuthPlugin, {
array $routesWhitelist,
?LoggerInterface $logger = null
) {
$this->routesWhitelist = $routesWhitelist; $this->routesWhitelist = $routesWhitelist;
$this->requestToAuthPlugin = $requestToAuthPlugin; $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 public function process(Request $request, RequestHandlerInterface $handler): Response
{ {
/** @var RouteResult|null $routeResult */ /** @var RouteResult|null $routeResult */
@ -67,33 +41,10 @@ class AuthenticationMiddleware implements MiddlewareInterface, StatusCodeInterfa
return $handler->handle($request); return $handler->handle($request);
} }
try {
$plugin = $this->requestToAuthPlugin->fromRequest($request); $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)
));
}
try {
$plugin->verify($request); $plugin->verify($request);
$response = $handler->handle($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( return $plugin->update($request, $response);
string $message,
string $errorCode = RestUtils::INVALID_AUTHORIZATION_ERROR
): JsonResponse {
return new JsonResponse([
'error' => $errorCode,
'message' => $message,
], self::STATUS_UNAUTHORIZED);
} }
} }

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