From 2ffbf03cf81fbd891e458f49a605ea81d4e29940 Mon Sep 17 00:00:00 2001
From: Alejandro Celaya <alejandro@alejandrocelaya.com>
Date: Sun, 12 Apr 2020 13:59:10 +0200
Subject: [PATCH] Created action to get mercure integration info

---
 config/autoload/mercure.global.php            | 16 +++++
 docs/swagger/definitions/MercureInfo.json     | 18 +++++
 docs/swagger/paths/v2_mercure-info.json       | 67 +++++++++++++++++++
 docs/swagger/swagger.json                     |  4 ++
 module/Rest/config/dependencies.config.php    |  3 +
 module/Rest/config/routes.config.php          |  2 +
 module/Rest/src/Action/MercureAction.php      | 56 ++++++++++++++++
 .../Rest/src/Exception/MercureException.php   | 30 +++++++++
 8 files changed, 196 insertions(+)
 create mode 100644 docs/swagger/definitions/MercureInfo.json
 create mode 100644 docs/swagger/paths/v2_mercure-info.json
 create mode 100644 module/Rest/src/Action/MercureAction.php
 create mode 100644 module/Rest/src/Exception/MercureException.php

diff --git a/config/autoload/mercure.global.php b/config/autoload/mercure.global.php
index e04336f3..3466ce64 100644
--- a/config/autoload/mercure.global.php
+++ b/config/autoload/mercure.global.php
@@ -2,6 +2,9 @@
 
 declare(strict_types=1);
 
+use Laminas\ServiceManager\Proxy\LazyServiceFactory;
+use Shlinkio\Shlink\Common\Mercure\LcobucciJwtProvider;
+
 return [
 
     'mercure' => [
@@ -12,4 +15,17 @@ return [
         'jwt_issuer' => 'Shlink',
     ],
 
+    'dependencies' => [
+        'delegators' => [
+            LcobucciJwtProvider::class => [
+                LazyServiceFactory::class,
+            ],
+        ],
+        'lazy_services' => [
+            'class_map' => [
+                LcobucciJwtProvider::class => LcobucciJwtProvider::class,
+            ],
+        ],
+    ],
+
 ];
diff --git a/docs/swagger/definitions/MercureInfo.json b/docs/swagger/definitions/MercureInfo.json
new file mode 100644
index 00000000..ac1f273a
--- /dev/null
+++ b/docs/swagger/definitions/MercureInfo.json
@@ -0,0 +1,18 @@
+{
+    "type": "object",
+    "required": ["mercureHubUrl", "jwt", "jwtExpiration"],
+    "properties": {
+        "mercureHubUrl": {
+            "type": "string",
+            "description": "The public URL of the mercure hub that can be used to get real-time updates published by Shlink"
+        },
+        "jwt": {
+            "type": "string",
+            "description": "A JWT with subscribe permissions which is valid with the mercure hub"
+        },
+        "jwtExpiration": {
+            "type": "string",
+            "description": "The date (in ISO-8601 format) in which the JWT will expire"
+        }
+    }
+}
diff --git a/docs/swagger/paths/v2_mercure-info.json b/docs/swagger/paths/v2_mercure-info.json
new file mode 100644
index 00000000..24f7fb5f
--- /dev/null
+++ b/docs/swagger/paths/v2_mercure-info.json
@@ -0,0 +1,67 @@
+{
+    "get": {
+        "operationId": "mercureInfo",
+        "tags": [
+            "Integrations"
+        ],
+        "summary": "Get mercure integration info",
+        "description": "Returns information to consume updates published by Shlink on a mercure hub. https://mercure.rocks/",
+        "parameters": [
+            {
+                "$ref": "../parameters/version.json"
+            }
+        ],
+        "security": [
+            {
+                "ApiKey": []
+            }
+        ],
+        "responses": {
+            "200": {
+                "description": "The mercure integration info",
+                "content": {
+                    "application/json": {
+                        "schema": {
+                            "$ref": "../definitions/MercureInfo.json"
+                        }
+                    }
+                },
+                "examples": {
+                    "application/json": {
+                        "mercureHubUrl": "https://example.com/.well-known/mercure",
+                        "jwt": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJTaGxpbmsiLCJpYXQiOjE1ODY2ODY3MzIsImV4cCI6MTU4Njk0NTkzMiwibWVyY3VyZSI6eyJzdWJzY3JpYmUiOltdfX0.P-519lgU7dFz0bbNlRG1CXyqugGbaHon4kw6fu4QBdQ",
+                        "jwtExpiration":  "2020-04-15T12:18:52+02:00"
+                    }
+                }
+            },
+            "501": {
+                "description": "This Shlink instance is not integrated with a mercure hub",
+                "content": {
+                    "application/problem+json": {
+                        "schema": {
+                            "$ref": "../definitions/Error.json"
+                        }
+                    }
+                },
+                "examples": {
+                    "application/json": {
+                        "title": "Mercure integration not configured",
+                        "type": "MERCURE_NOT_CONFIGURED",
+                        "detail": "This Shlink instance is not integrated with a mercure hub.",
+                        "status": 501
+                    }
+                }
+            },
+            "500": {
+                "description": "Unexpected error.",
+                "content": {
+                    "application/problem+json": {
+                        "schema": {
+                            "$ref": "../definitions/Error.json"
+                        }
+                    }
+                }
+            }
+        }
+    }
+}
diff --git a/docs/swagger/swagger.json b/docs/swagger/swagger.json
index 32e0caf3..c30bab97 100644
--- a/docs/swagger/swagger.json
+++ b/docs/swagger/swagger.json
@@ -82,6 +82,10 @@
             "$ref": "paths/v1_short-urls_{shortCode}_visits.json"
         },
 
+        "/rest/v{version}/mercure-info": {
+            "$ref": "paths/v2_mercure-info.json"
+        },
+
         "/rest/health": {
             "$ref": "paths/health.json"
         },
diff --git a/module/Rest/config/dependencies.config.php b/module/Rest/config/dependencies.config.php
index b24ec1ee..e9fcbfa5 100644
--- a/module/Rest/config/dependencies.config.php
+++ b/module/Rest/config/dependencies.config.php
@@ -9,6 +9,7 @@ use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
 use Laminas\ServiceManager\Factory\InvokableFactory;
 use Mezzio\Router\Middleware\ImplicitOptionsMiddleware;
 use Psr\Log\LoggerInterface;
+use Shlinkio\Shlink\Common\Mercure\LcobucciJwtProvider;
 use Shlinkio\Shlink\Core\Options\AppOptions;
 use Shlinkio\Shlink\Core\Service;
 use Shlinkio\Shlink\Rest\Service\ApiKeyService;
@@ -20,6 +21,7 @@ return [
             ApiKeyService::class => ConfigAbstractFactory::class,
 
             Action\HealthAction::class => ConfigAbstractFactory::class,
+            Action\MercureAction::class => ConfigAbstractFactory::class,
             Action\ShortUrl\CreateShortUrlAction::class => ConfigAbstractFactory::class,
             Action\ShortUrl\SingleStepCreateShortUrlAction::class => ConfigAbstractFactory::class,
             Action\ShortUrl\EditShortUrlAction::class => ConfigAbstractFactory::class,
@@ -46,6 +48,7 @@ return [
         ApiKeyService::class => ['em'],
 
         Action\HealthAction::class => [Connection::class, AppOptions::class, 'Logger_Shlink'],
+        Action\MercureAction::class => [LcobucciJwtProvider::class, 'config.mercure', 'Logger_Shlink'],
         Action\ShortUrl\CreateShortUrlAction::class => [
             Service\UrlShortener::class,
             'config.url_shortener.domain',
diff --git a/module/Rest/config/routes.config.php b/module/Rest/config/routes.config.php
index b104d81b..afb44249 100644
--- a/module/Rest/config/routes.config.php
+++ b/module/Rest/config/routes.config.php
@@ -33,6 +33,8 @@ return [
         Action\Tag\DeleteTagsAction::getRouteDef(),
         Action\Tag\CreateTagsAction::getRouteDef(),
         Action\Tag\UpdateTagAction::getRouteDef(),
+
+        Action\MercureAction::getRouteDef(),
     ],
 
 ];
diff --git a/module/Rest/src/Action/MercureAction.php b/module/Rest/src/Action/MercureAction.php
new file mode 100644
index 00000000..7c33fa31
--- /dev/null
+++ b/module/Rest/src/Action/MercureAction.php
@@ -0,0 +1,56 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shlinkio\Shlink\Rest\Action;
+
+use Cake\Chronos\Chronos;
+use Laminas\Diactoros\Response\JsonResponse;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use Psr\Log\LoggerInterface;
+use Shlinkio\Shlink\Common\Mercure\JwtProviderInterface;
+use Shlinkio\Shlink\Rest\Exception\MercureException;
+use Throwable;
+
+class MercureAction extends AbstractRestAction
+{
+    protected const ROUTE_PATH = '/mercure-info';
+    protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET];
+
+    private JwtProviderInterface $jwtProvider;
+    private array $mercureConfig;
+
+    public function __construct(
+        JwtProviderInterface $jwtProvider,
+        array $mercureConfig,
+        ?LoggerInterface $logger = null
+    ) {
+        parent::__construct($logger);
+        $this->jwtProvider = $jwtProvider;
+        $this->mercureConfig = $mercureConfig;
+    }
+
+    public function handle(ServerRequestInterface $request): ResponseInterface
+    {
+        $hubUrl = $this->mercureConfig['public_hub_url'] ?? null;
+        if ($hubUrl === null) {
+            throw MercureException::mercureNotConfigured();
+        }
+
+        $days = $this->mercureConfig['jwt_days_duration'] ?? 3;
+        $expiresAt = Chronos::now()->addDays($days);
+
+        try {
+            $jwt = $this->jwtProvider->buildSubscriptionToken($expiresAt);
+        } catch (Throwable $e) {
+            throw MercureException::mercureNotConfigured($e);
+        }
+
+        return new JsonResponse([
+            'mercureHubUrl' => $hubUrl,
+            'token' => $jwt,
+            'jwtExpiration' => $expiresAt->toAtomString(),
+        ]);
+    }
+}
diff --git a/module/Rest/src/Exception/MercureException.php b/module/Rest/src/Exception/MercureException.php
new file mode 100644
index 00000000..6c318e93
--- /dev/null
+++ b/module/Rest/src/Exception/MercureException.php
@@ -0,0 +1,30 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Shlinkio\Shlink\Rest\Exception;
+
+use Fig\Http\Message\StatusCodeInterface;
+use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait;
+use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
+use Throwable;
+
+class MercureException extends RuntimeException implements ProblemDetailsExceptionInterface
+{
+    use CommonProblemDetailsExceptionTrait;
+
+    private const TITLE = 'Mercure integration not configured';
+    private const TYPE = 'MERCURE_NOT_CONFIGURED';
+
+    public static function mercureNotConfigured(?Throwable $prev = null): self
+    {
+        $e = new self('This Shlink instance is not integrated with a mercure hub.', 1, $prev);
+
+        $e->detail = $e->getMessage();
+        $e->title = self::TITLE;
+        $e->type = self::TYPE;
+        $e->status = StatusCodeInterface::STATUS_NOT_IMPLEMENTED;
+
+        return $e;
+    }
+}