Merge pull request #331 from acelaya/feature/health

Feature/health
This commit is contained in:
Alejandro Celaya 2018-12-29 14:51:08 +01:00 committed by GitHub
commit 1fd3e6365e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 349 additions and 5 deletions

View file

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

View file

@ -1,4 +1,6 @@
<?php
declare(strict_types=1);
use Zend\Expressive\Container\WhoopsErrorResponseGeneratorFactory;
return [

View file

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

View file

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

View file

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

View file

@ -10,6 +10,7 @@ return [
'auth' => [
'routes_whitelist' => [
Action\AuthenticateAction::class,
Action\HealthAction::class,
Action\ShortUrl\SingleStepCreateShortUrlAction::class,
],

View file

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

View file

@ -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([

View file

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

View file

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Rest\Action;
use Doctrine\DBAL\Connection;
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
{
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];
/** @var AppOptions */
private $options;
/** @var Connection */
private $conn;
public function __construct(Connection $conn, AppOptions $options, LoggerInterface $logger = null)
{
parent::__construct($logger);
$this->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]);
}
}

View file

@ -0,0 +1,19 @@
<?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

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

View file

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

View file

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Rest\Action;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManager;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Options\AppOptions;
use Shlinkio\Shlink\Rest\Action;
use Zend\ServiceManager\ServiceManager;
class HealthActionFactoryTest extends TestCase
{
/** @var Action\HealthActionFactory */
private $factory;
public function setUp()
{
$this->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();
}
}

View file

@ -0,0 +1,93 @@
<?php
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;
use Shlinkio\Shlink\Rest\Action\HealthAction;
use Zend\Diactoros\Response\JsonResponse;
use Zend\Diactoros\ServerRequest;
class HealthActionTest extends TestCase
{
/** @var HealthAction */
private $action;
/** @var ObjectProphecy */
private $conn;
public function setUp()
{
$this->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();
}
}