diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a7410ac..76e14a26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this ## [Unreleased] ### Added * [#1089](https://github.com/shlinkio/shlink/issues/1089) Added new `ENABLE_PERIODIC_VISIT_LOCATE` env var to docker image which schedules the `visit:locate` command every hour when provided with value `true`. +* [#1082](https://github.com/shlinkio/shlink/issues/1082) Added support for error correction level on QR codes. + + Now, when calling the `GET /{shorCode}/qr-code` URL, you can pass the `errorCorrection` query param with values `L` for Low, `M` for Medium, `Q` for Quartile or `H` for High. ### Changed * *Nothing* diff --git a/docs/swagger/paths/{shortCode}_qr-code.json b/docs/swagger/paths/{shortCode}_qr-code.json index 00502ad5..04a88fd7 100644 --- a/docs/swagger/paths/{shortCode}_qr-code.json +++ b/docs/swagger/paths/{shortCode}_qr-code.json @@ -5,7 +5,7 @@ "URL Shortener" ], "summary": "Short URL QR code", - "description": "Generates a QR code image pointing to a short URL", + "description": "Generates a QR code image pointing to a short URL.
Since this is not an API endpoint but an image one, when an invalid value is provided for any of the query params, they will fall to their default values instead of throwing an error.", "parameters": [ { "name": "shortCode", @@ -35,10 +35,8 @@ "required": false, "schema": { "type": "string", - "enum": [ - "png", - "svg" - ] + "enum": ["png", "svg"], + "default": "png" } }, { @@ -51,6 +49,17 @@ "minimum": 0, "default": 0 } + }, + { + "name": "errorCorrection", + "in": "query", + "description": "The error correction level to apply to the the QR code: **[L]**ow, **[M]**edium, **[Q]**uartile or **[H]**igh. See [docs](https://www.qrcode.com/en/about/error_correction.html).", + "required": false, + "schema": { + "type": "string", + "enum": ["L", "M", "Q", "H"], + "default": "L" + } } ], "responses": { diff --git a/module/Core/src/Action/Model/QrCodeParams.php b/module/Core/src/Action/Model/QrCodeParams.php new file mode 100644 index 00000000..742d3f07 --- /dev/null +++ b/module/Core/src/Action/Model/QrCodeParams.php @@ -0,0 +1,112 @@ +getQueryParams(); + + return new self( + self::resolveSize($request, $query), + self::resolveMargin($query), + self::resolveWriter($query), + self::resolveErrorCorrection($query), + ); + } + + private static function resolveSize(Request $request, array $query): int + { + // FIXME Size attribute is deprecated. After v3.0.0, always use the query param instead + $size = (int) $request->getAttribute('size', $query['size'] ?? self::DEFAULT_SIZE); + if ($size < self::MIN_SIZE) { + return self::MIN_SIZE; + } + + return $size > self::MAX_SIZE ? self::MAX_SIZE : $size; + } + + private static function resolveMargin(array $query): int + { + $margin = $query['margin'] ?? null; + if ($margin === null) { + return 0; + } + + $intMargin = (int) $margin; + if ($margin !== (string) $intMargin) { + return 0; + } + + return $intMargin < 0 ? 0 : $intMargin; + } + + private static function resolveWriter(array $query): WriterInterface + { + $format = strtolower(trim($query['format'] ?? 'png')); + return match ($format) { + 'svg' => new SvgWriter(), + default => new PngWriter(), + }; + } + + private static function resolveErrorCorrection(array $query): ErrorCorrectionLevelInterface + { + $errorCorrectionLevel = strtolower(trim($query['errorCorrection'] ?? 'l')); + return match ($errorCorrectionLevel) { + 'h' => new ErrorCorrectionLevelHigh(), + 'q' => new ErrorCorrectionLevelQuartile(), + 'm' => new ErrorCorrectionLevelMedium(), + default => new ErrorCorrectionLevelLow(), // 'l' + }; + } + + public function size(): int + { + return $this->size; + } + + public function margin(): int + { + return $this->margin; + } + + public function writer(): WriterInterface + { + return $this->writer; + } + + public function errorCorrectionLevel(): ErrorCorrectionLevelInterface + { + return $this->errorCorrectionLevel; + } +} diff --git a/module/Core/src/Action/QrCodeAction.php b/module/Core/src/Action/QrCodeAction.php index 177d90fc..2f816c98 100644 --- a/module/Core/src/Action/QrCodeAction.php +++ b/module/Core/src/Action/QrCodeAction.php @@ -5,14 +5,13 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Action; use Endroid\QrCode\Builder\Builder; -use Endroid\QrCode\Writer\SvgWriter; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; use Psr\Log\LoggerInterface; -use Psr\Log\NullLogger; use Shlinkio\Shlink\Common\Response\QrCodeResponse; +use Shlinkio\Shlink\Core\Action\Model\QrCodeParams; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface; @@ -20,18 +19,11 @@ use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface; class QrCodeAction implements MiddlewareInterface { - private const DEFAULT_SIZE = 300; - private const MIN_SIZE = 50; - private const MAX_SIZE = 1000; - - private LoggerInterface $logger; - public function __construct( private ShortUrlResolverInterface $urlResolver, private ShortUrlStringifierInterface $stringifier, - ?LoggerInterface $logger = null + private LoggerInterface $logger ) { - $this->logger = $logger ?? new NullLogger(); } public function process(Request $request, RequestHandlerInterface $handler): Response @@ -45,43 +37,14 @@ class QrCodeAction implements MiddlewareInterface return $handler->handle($request); } - $query = $request->getQueryParams(); - $qrCode = Builder::create() + $params = QrCodeParams::fromRequest($request); + $qrCodeBuilder = Builder::create() ->data($this->stringifier->stringify($shortUrl)) - ->size($this->resolveSize($request, $query)) - ->margin($this->resolveMargin($query)); + ->size($params->size()) + ->margin($params->margin()) + ->writer($params->writer()) + ->errorCorrectionLevel($params->errorCorrectionLevel()); - $format = $query['format'] ?? 'png'; - if ($format === 'svg') { - $qrCode->writer(new SvgWriter()); - } - - return new QrCodeResponse($qrCode->build()); - } - - private function resolveSize(Request $request, array $query): int - { - // Size attribute is deprecated. After v3.0.0, always use the query param instead - $size = (int) $request->getAttribute('size', $query['size'] ?? self::DEFAULT_SIZE); - if ($size < self::MIN_SIZE) { - return self::MIN_SIZE; - } - - return $size > self::MAX_SIZE ? self::MAX_SIZE : $size; - } - - private function resolveMargin(array $query): int - { - if (! isset($query['margin'])) { - return 0; - } - - $margin = $query['margin']; - $intMargin = (int) $margin; - if ($margin !== (string) $intMargin) { - return 0; - } - - return $intMargin < 0 ? 0 : $intMargin; + return new QrCodeResponse($qrCodeBuilder->build()); } } diff --git a/module/Core/test/Action/QrCodeActionTest.php b/module/Core/test/Action/QrCodeActionTest.php index 7326c41c..0595734e 100644 --- a/module/Core/test/Action/QrCodeActionTest.php +++ b/module/Core/test/Action/QrCodeActionTest.php @@ -14,6 +14,7 @@ use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; +use Psr\Log\NullLogger; use Shlinkio\Shlink\Common\Response\QrCodeResponse; use Shlinkio\Shlink\Core\Action\QrCodeAction; use Shlinkio\Shlink\Core\Entity\ShortUrl; @@ -41,6 +42,7 @@ class QrCodeActionTest extends TestCase $this->action = new QrCodeAction( $this->urlResolver->reveal(), new ShortUrlStringifier(['domain' => 'doma.in']), + new NullLogger(), ); }