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 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/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..2cb08030 --- /dev/null +++ b/module/Rest/src/Action/HealthAction.php @@ -0,0 +1,58 @@ +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 + { + 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(), + '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..56cfd740 100644 --- a/module/Rest/src/ConfigProvider.php +++ b/module/Rest/src/ConfigProvider.php @@ -5,10 +5,11 @@ 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/v{version:1}'; public function __invoke() { @@ -23,7 +24,8 @@ class ConfigProvider // Prepend the routes prefix to every path foreach ($routes as $key => $route) { - $routes[$key]['path'] = self::ROUTES_PREFIX . $route['path']; + ['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. 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..efca74b4 --- /dev/null +++ b/module/Rest/test/Action/HealthActionTest.php @@ -0,0 +1,93 @@ +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(); + } + + /** + * @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(); + } +}