From 3f65ef998c8001452de403bf7fe63946364ac801 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 23 Dec 2018 10:18:38 +0100 Subject: [PATCH 1/6] Created HealthAction --- config/autoload/errorhandler.local.php.dist | 2 + module/Rest/config/auth.config.php | 1 + module/Rest/config/dependencies.config.php | 1 + module/Rest/config/routes.config.php | 3 +- module/Rest/src/Action/AbstractRestAction.php | 2 + module/Rest/src/Action/AuthenticateAction.php | 1 + module/Rest/src/Action/HealthAction.php | 52 +++++++++++++++++++ .../Rest/src/Action/HealthActionFactory.php | 19 +++++++ module/Rest/src/ConfigProvider.php | 13 ++++- 9 files changed, 90 insertions(+), 4 deletions(-) create mode 100644 module/Rest/src/Action/HealthAction.php create mode 100644 module/Rest/src/Action/HealthActionFactory.php diff --git a/config/autoload/errorhandler.local.php.dist b/config/autoload/errorhandler.local.php.dist index 552b6ffb..6dea98cb 100644 --- a/config/autoload/errorhandler.local.php.dist +++ b/config/autoload/errorhandler.local.php.dist @@ -1,4 +1,6 @@ [ 'routes_whitelist' => [ Action\AuthenticateAction::class, + Action\HealthAction::class, Action\ShortUrl\SingleStepCreateShortUrlAction::class, ], diff --git a/module/Rest/config/dependencies.config.php b/module/Rest/config/dependencies.config.php index ab2d9901..27e86a35 100644 --- a/module/Rest/config/dependencies.config.php +++ b/module/Rest/config/dependencies.config.php @@ -18,6 +18,7 @@ return [ ApiKeyService::class => ConfigAbstractFactory::class, Action\AuthenticateAction::class => ConfigAbstractFactory::class, + Action\HealthAction::class => Action\HealthActionFactory::class, Action\ShortUrl\CreateShortUrlAction::class => ConfigAbstractFactory::class, Action\ShortUrl\SingleStepCreateShortUrlAction::class => ConfigAbstractFactory::class, Action\ShortUrl\EditShortUrlAction::class => ConfigAbstractFactory::class, diff --git a/module/Rest/config/routes.config.php b/module/Rest/config/routes.config.php index a937bc42..b3586fda 100644 --- a/module/Rest/config/routes.config.php +++ b/module/Rest/config/routes.config.php @@ -3,12 +3,11 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Rest; -use Shlinkio\Shlink\Rest\Action; - return [ 'routes' => [ Action\AuthenticateAction::getRouteDef(), + Action\HealthAction::getRouteDef(), // Short codes Action\ShortUrl\CreateShortUrlAction::getRouteDef([ diff --git a/module/Rest/src/Action/AbstractRestAction.php b/module/Rest/src/Action/AbstractRestAction.php index 21e15d66..a9c449c4 100644 --- a/module/Rest/src/Action/AbstractRestAction.php +++ b/module/Rest/src/Action/AbstractRestAction.php @@ -14,6 +14,7 @@ abstract class AbstractRestAction implements RequestHandlerInterface, RequestMet { protected const ROUTE_PATH = ''; protected const ROUTE_ALLOWED_METHODS = []; + protected const ROUTE_CAN_BE_VERSIONED = true; /** @var LoggerInterface */ protected $logger; @@ -30,6 +31,7 @@ abstract class AbstractRestAction implements RequestHandlerInterface, RequestMet 'middleware' => array_merge($prevMiddleware, [static::class], $postMiddleware), 'path' => static::ROUTE_PATH, 'allowed_methods' => static::ROUTE_ALLOWED_METHODS, + 'can_be_versioned' => static::ROUTE_CAN_BE_VERSIONED, ]; } } diff --git a/module/Rest/src/Action/AuthenticateAction.php b/module/Rest/src/Action/AuthenticateAction.php index 62a21e99..e310e48f 100644 --- a/module/Rest/src/Action/AuthenticateAction.php +++ b/module/Rest/src/Action/AuthenticateAction.php @@ -12,6 +12,7 @@ use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface; use Shlinkio\Shlink\Rest\Util\RestUtils; use Zend\Diactoros\Response\JsonResponse; +/** @deprecated */ class AuthenticateAction extends AbstractRestAction { protected const ROUTE_PATH = '/authenticate'; diff --git a/module/Rest/src/Action/HealthAction.php b/module/Rest/src/Action/HealthAction.php new file mode 100644 index 00000000..35af83fa --- /dev/null +++ b/module/Rest/src/Action/HealthAction.php @@ -0,0 +1,52 @@ +conn = $conn; + $this->options = $options; + } + + /** + * Handles a request and produces a response. + * + * May call other collaborating code to generate the response. + */ + public function handle(ServerRequestInterface $request): ResponseInterface + { + $connected = $this->conn->ping(); + $statusCode = $connected ? self::STATUS_OK : self::STATUS_SERVICE_UNAVAILABLE; + + return new JsonResponse([ + 'status' => $connected ? 'pass' : 'fail', + 'version' => $this->options->getVersion(), + 'links' => [ + 'about' => 'https://shlink.io', + 'project' => 'https://github.com/shlinkio/shlink', + ], + ], $statusCode, ['Content-type' => self::HEALTH_CONTENT_TYPE]); + } +} diff --git a/module/Rest/src/Action/HealthActionFactory.php b/module/Rest/src/Action/HealthActionFactory.php new file mode 100644 index 00000000..fdd85ba8 --- /dev/null +++ b/module/Rest/src/Action/HealthActionFactory.php @@ -0,0 +1,19 @@ +get(EntityManager::class); + $options = $container->get(AppOptions::class); + $logger = $container->get('Logger_Shlink'); + return new HealthAction($em->getConnection(), $options, $logger); + } +} diff --git a/module/Rest/src/ConfigProvider.php b/module/Rest/src/ConfigProvider.php index c6afdcfe..a951161b 100644 --- a/module/Rest/src/ConfigProvider.php +++ b/module/Rest/src/ConfigProvider.php @@ -5,10 +5,12 @@ namespace Shlinkio\Shlink\Rest; use Zend\Config\Factory; use Zend\Stdlib\Glob; +use function sprintf; class ConfigProvider { - const ROUTES_PREFIX = '/rest/v{version:1}'; + private const ROUTES_PREFIX = '/rest'; + private const ROUTES_VERSION_PARAM = '/v{version:1}'; public function __invoke() { @@ -23,7 +25,14 @@ class ConfigProvider // Prepend the routes prefix to every path foreach ($routes as $key => $route) { - $routes[$key]['path'] = self::ROUTES_PREFIX . $route['path']; + ['can_be_versioned' => $routeCanBeVersioned, 'path' => $path] = $route; + $routes[$key]['path'] = sprintf( + '%s%s%s', + self::ROUTES_PREFIX, + $routeCanBeVersioned ? self::ROUTES_VERSION_PARAM : '', + $path + ); + unset($routes[$key]['can_be_versioned']); } return $config; From 0f86123ccbd463f8a6ece72c299d509dceb64b41 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 29 Dec 2018 11:24:55 +0100 Subject: [PATCH 2/6] Finished health action implementation --- module/Rest/src/Action/AbstractRestAction.php | 2 -- module/Rest/src/Action/HealthAction.php | 5 +++-- module/Rest/src/ConfigProvider.php | 13 +++---------- .../Rest/src/Middleware/PathVersionMiddleware.php | 3 +++ 4 files changed, 9 insertions(+), 14 deletions(-) diff --git a/module/Rest/src/Action/AbstractRestAction.php b/module/Rest/src/Action/AbstractRestAction.php index a9c449c4..21e15d66 100644 --- a/module/Rest/src/Action/AbstractRestAction.php +++ b/module/Rest/src/Action/AbstractRestAction.php @@ -14,7 +14,6 @@ abstract class AbstractRestAction implements RequestHandlerInterface, RequestMet { protected const ROUTE_PATH = ''; protected const ROUTE_ALLOWED_METHODS = []; - protected const ROUTE_CAN_BE_VERSIONED = true; /** @var LoggerInterface */ protected $logger; @@ -31,7 +30,6 @@ abstract class AbstractRestAction implements RequestHandlerInterface, RequestMet 'middleware' => array_merge($prevMiddleware, [static::class], $postMiddleware), 'path' => static::ROUTE_PATH, 'allowed_methods' => static::ROUTE_ALLOWED_METHODS, - 'can_be_versioned' => static::ROUTE_CAN_BE_VERSIONED, ]; } } diff --git a/module/Rest/src/Action/HealthAction.php b/module/Rest/src/Action/HealthAction.php index 35af83fa..d062d5b2 100644 --- a/module/Rest/src/Action/HealthAction.php +++ b/module/Rest/src/Action/HealthAction.php @@ -13,10 +13,11 @@ use Zend\Diactoros\Response\JsonResponse; class HealthAction extends AbstractRestAction { private const HEALTH_CONTENT_TYPE = 'application/health+json'; + private const PASS_STATUS = 'pass'; + private const FAIL_STATUS = 'fail'; protected const ROUTE_PATH = '/health'; protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET]; - protected const ROUTE_CAN_BE_VERSIONED = false; /** @var AppOptions */ private $options; @@ -41,7 +42,7 @@ class HealthAction extends AbstractRestAction $statusCode = $connected ? self::STATUS_OK : self::STATUS_SERVICE_UNAVAILABLE; return new JsonResponse([ - 'status' => $connected ? 'pass' : 'fail', + 'status' => $connected ? self::PASS_STATUS : self::FAIL_STATUS, 'version' => $this->options->getVersion(), 'links' => [ 'about' => 'https://shlink.io', diff --git a/module/Rest/src/ConfigProvider.php b/module/Rest/src/ConfigProvider.php index a951161b..56cfd740 100644 --- a/module/Rest/src/ConfigProvider.php +++ b/module/Rest/src/ConfigProvider.php @@ -9,8 +9,7 @@ use function sprintf; class ConfigProvider { - private const ROUTES_PREFIX = '/rest'; - private const ROUTES_VERSION_PARAM = '/v{version:1}'; + private const ROUTES_PREFIX = '/rest/v{version:1}'; public function __invoke() { @@ -25,14 +24,8 @@ class ConfigProvider // Prepend the routes prefix to every path foreach ($routes as $key => $route) { - ['can_be_versioned' => $routeCanBeVersioned, 'path' => $path] = $route; - $routes[$key]['path'] = sprintf( - '%s%s%s', - self::ROUTES_PREFIX, - $routeCanBeVersioned ? self::ROUTES_VERSION_PARAM : '', - $path - ); - unset($routes[$key]['can_be_versioned']); + ['path' => $path] = $route; + $routes[$key]['path'] = sprintf('%s%s', self::ROUTES_PREFIX, $path); } return $config; diff --git a/module/Rest/src/Middleware/PathVersionMiddleware.php b/module/Rest/src/Middleware/PathVersionMiddleware.php index cc28be0b..d39c8a2f 100644 --- a/module/Rest/src/Middleware/PathVersionMiddleware.php +++ b/module/Rest/src/Middleware/PathVersionMiddleware.php @@ -11,6 +11,9 @@ use function strpos; class PathVersionMiddleware implements MiddlewareInterface { + // TODO The /health endpoint needs this middleware in order to work without the version. + // Take it into account if this middleware is ever removed. + /** * Process an incoming server request and return a response, optionally delegating * to the next middleware component to create the response. From d58e24bce5a9ba430fd77962561345577f2c1dac Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 29 Dec 2018 11:39:55 +0100 Subject: [PATCH 3/6] Created health action related tests --- .../test/Action/HealthActionFactoryTest.php | 45 ++++++++++++ module/Rest/test/Action/HealthActionTest.php | 70 +++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 module/Rest/test/Action/HealthActionFactoryTest.php create mode 100644 module/Rest/test/Action/HealthActionTest.php diff --git a/module/Rest/test/Action/HealthActionFactoryTest.php b/module/Rest/test/Action/HealthActionFactoryTest.php new file mode 100644 index 00000000..e5130d62 --- /dev/null +++ b/module/Rest/test/Action/HealthActionFactoryTest.php @@ -0,0 +1,45 @@ +factory = new Action\HealthActionFactory(); + } + + /** + * @test + */ + public function serviceIsCreatedExtractingConnectionFromEntityManager() + { + $em = $this->prophesize(EntityManager::class); + $conn = $this->prophesize(Connection::class); + + $getConnection = $em->getConnection()->willReturn($conn->reveal()); + + $sm = new ServiceManager(['services' => [ + 'Logger_Shlink' => $this->prophesize(LoggerInterface::class)->reveal(), + AppOptions::class => $this->prophesize(AppOptions::class)->reveal(), + EntityManager::class => $em->reveal(), + ]]); + + $instance = ($this->factory)($sm, ''); + + $this->assertInstanceOf(Action\HealthAction::class, $instance); + $getConnection->shouldHaveBeenCalledOnce(); + } +} diff --git a/module/Rest/test/Action/HealthActionTest.php b/module/Rest/test/Action/HealthActionTest.php new file mode 100644 index 00000000..93371876 --- /dev/null +++ b/module/Rest/test/Action/HealthActionTest.php @@ -0,0 +1,70 @@ +conn = $this->prophesize(Connection::class); + $this->action = new HealthAction($this->conn->reveal(), new AppOptions(['version' => '1.2.3'])); + } + + /** + * @test + */ + public function passResponseIsReturnedWhenConnectionSucceeds() + { + $ping = $this->conn->ping()->willReturn(true); + + /** @var JsonResponse $resp */ + $resp = $this->action->handle(new ServerRequest()); + $payload = $resp->getPayload(); + + $this->assertEquals(200, $resp->getStatusCode()); + $this->assertEquals('pass', $payload['status']); + $this->assertEquals('1.2.3', $payload['version']); + $this->assertEquals([ + 'about' => 'https://shlink.io', + 'project' => 'https://github.com/shlinkio/shlink', + ], $payload['links']); + $this->assertEquals('application/health+json', $resp->getHeaderLine('Content-type')); + $ping->shouldHaveBeenCalledOnce(); + } + + /** + * @test + */ + public function failResponseIsReturnedWhenConnectionFails() + { + $ping = $this->conn->ping()->willReturn(false); + + /** @var JsonResponse $resp */ + $resp = $this->action->handle(new ServerRequest()); + $payload = $resp->getPayload(); + + $this->assertEquals(503, $resp->getStatusCode()); + $this->assertEquals('fail', $payload['status']); + $this->assertEquals('1.2.3', $payload['version']); + $this->assertEquals([ + 'about' => 'https://shlink.io', + 'project' => 'https://github.com/shlinkio/shlink', + ], $payload['links']); + $this->assertEquals('application/health+json', $resp->getHeaderLine('Content-type')); + $ping->shouldHaveBeenCalledOnce(); + } +} From 144a5415da7f0fdbbd6f84b0918c7daae4dfbf4a Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 29 Dec 2018 13:50:42 +0100 Subject: [PATCH 4/6] Handled connection exceptions in Health action --- module/Rest/src/Action/HealthAction.php | 9 ++++++-- module/Rest/test/Action/HealthActionTest.php | 23 ++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/module/Rest/src/Action/HealthAction.php b/module/Rest/src/Action/HealthAction.php index d062d5b2..2cb08030 100644 --- a/module/Rest/src/Action/HealthAction.php +++ b/module/Rest/src/Action/HealthAction.php @@ -8,6 +8,7 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Log\LoggerInterface; use Shlinkio\Shlink\Core\Options\AppOptions; +use Throwable; use Zend\Diactoros\Response\JsonResponse; class HealthAction extends AbstractRestAction @@ -38,9 +39,13 @@ class HealthAction extends AbstractRestAction */ public function handle(ServerRequestInterface $request): ResponseInterface { - $connected = $this->conn->ping(); - $statusCode = $connected ? self::STATUS_OK : self::STATUS_SERVICE_UNAVAILABLE; + try { + $connected = $this->conn->ping(); + } catch (Throwable $e) { + $connected = false; + } + $statusCode = $connected ? self::STATUS_OK : self::STATUS_SERVICE_UNAVAILABLE; return new JsonResponse([ 'status' => $connected ? self::PASS_STATUS : self::FAIL_STATUS, 'version' => $this->options->getVersion(), diff --git a/module/Rest/test/Action/HealthActionTest.php b/module/Rest/test/Action/HealthActionTest.php index 93371876..efca74b4 100644 --- a/module/Rest/test/Action/HealthActionTest.php +++ b/module/Rest/test/Action/HealthActionTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Rest\Action; use Doctrine\DBAL\Connection; +use Exception; use PHPUnit\Framework\TestCase; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Options\AppOptions; @@ -67,4 +68,26 @@ class HealthActionTest extends TestCase $this->assertEquals('application/health+json', $resp->getHeaderLine('Content-type')); $ping->shouldHaveBeenCalledOnce(); } + + /** + * @test + */ + public function failResponseIsReturnedWhenConnectionThrowsException() + { + $ping = $this->conn->ping()->willThrow(Exception::class); + + /** @var JsonResponse $resp */ + $resp = $this->action->handle(new ServerRequest()); + $payload = $resp->getPayload(); + + $this->assertEquals(503, $resp->getStatusCode()); + $this->assertEquals('fail', $payload['status']); + $this->assertEquals('1.2.3', $payload['version']); + $this->assertEquals([ + 'about' => 'https://shlink.io', + 'project' => 'https://github.com/shlinkio/shlink', + ], $payload['links']); + $this->assertEquals('application/health+json', $resp->getHeaderLine('Content-type')); + $ping->shouldHaveBeenCalledOnce(); + } } From fd8d73af38808c4598b3b241d66c3832b75c3da2 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 29 Dec 2018 14:39:31 +0100 Subject: [PATCH 5/6] Documented health endpoint --- docs/swagger/definitions/Health.json | 31 ++++++++++++++ docs/swagger/paths/health.json | 62 ++++++++++++++++++++++++++++ docs/swagger/swagger.json | 8 ++++ 3 files changed, 101 insertions(+) create mode 100644 docs/swagger/definitions/Health.json create mode 100644 docs/swagger/paths/health.json diff --git a/docs/swagger/definitions/Health.json b/docs/swagger/definitions/Health.json new file mode 100644 index 00000000..3683989a --- /dev/null +++ b/docs/swagger/definitions/Health.json @@ -0,0 +1,31 @@ +{ + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "pass", + "fail" + ], + "description": "The status of the service" + }, + "version": { + "type": "string", + "description": "Shlink version" + }, + "links": { + "type": "object", + "properties": { + "about": { + "type": "string", + "description": "About shlink" + }, + "project": { + "type": "string", + "description": "Shlink project repository" + } + }, + "description": "A list of links" + } + } +} diff --git a/docs/swagger/paths/health.json b/docs/swagger/paths/health.json new file mode 100644 index 00000000..60d96ccc --- /dev/null +++ b/docs/swagger/paths/health.json @@ -0,0 +1,62 @@ +{ + "get": { + "operationId": "health", + "tags": [ + "Monitoring" + ], + "summary": "Check healthiness", + "description": "Checks the healthiness of the service, making sure it can access required resources.", + "responses": { + "200": { + "description": "The passing health status", + "content": { + "application/json": { + "schema": { + "$ref": "../definitions/Health.json" + } + } + }, + "examples": { + "application/json": { + "status": "pass", + "version": "1.16.0", + "links": { + "about": "https://shlink.io", + "project": "https://github.com/shlinkio/shlink" + } + } + } + }, + "503": { + "description": "The failing health status", + "content": { + "application/json": { + "schema": { + "$ref": "../definitions/Health.json" + } + } + }, + "examples": { + "application/json": { + "status": "fail", + "version": "1.16.0", + "links": { + "about": "https://shlink.io", + "project": "https://github.com/shlinkio/shlink" + } + } + } + }, + "500": { + "description": "Unexpected error.", + "content": { + "application/json": { + "schema": { + "$ref": "../definitions/Error.json" + } + } + } + } + } + } +} diff --git a/docs/swagger/swagger.json b/docs/swagger/swagger.json index 0ba8231d..8f996886 100644 --- a/docs/swagger/swagger.json +++ b/docs/swagger/swagger.json @@ -56,6 +56,10 @@ "name": "Visits", "description": "Operations to manage visits on short URLs" }, + { + "name": "Monitoring", + "description": "Public endpoints designed to monitor the service" + }, { "name": "URL Shortener", "description": "Non-rest endpoints, used to be publicly exposed" @@ -88,6 +92,10 @@ "$ref": "paths/v1_short-urls_{shortCode}_visits.json" }, + "/rest/health": { + "$ref": "paths/health.json" + }, + "/{shortCode}": { "$ref": "paths/{shortCode}.json" }, From 28989296eb45529350df86c8b3780213ce5ae317 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 29 Dec 2018 14:45:20 +0100 Subject: [PATCH 6/6] Updated changelog --- CHANGELOG.md | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ebdc626b..4ac60d4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), #### Added -* *Nothing* +* [#304](https://github.com/shlinkio/shlink/issues/304) Added health endpoint to check healthiness of the service. Useful in container-based infrastructures. + + Call [GET /rest/health] in order to get a response like this: + + ```http + HTTP/1.1 200 OK + Content-Type: application/health+json + Content-Length: 681 + + { + "status": "pass", + "version": "1.16.0", + "links": { + "about": "https://shlink.io", + "project": "https://github.com/shlinkio/shlink" + } + } + ``` + + The status code can be `200 OK` in case of success or `503 Service Unavailable` in case of error, while the `status` property will be one of `pass` or `fail`, as defined in the [Health check RFC](https://inadarei.github.io/rfc-healthcheck/). #### Changed