From 40af803ebc3e1b0340a60a3b019ce36ee87c4893 Mon Sep 17 00:00:00 2001 From: Jim Safley Date: Fri, 22 May 2026 20:52:17 -0400 Subject: [PATCH 1/4] Drop laminas/laminas-json dependency The package is abandoned upstream. The laminas-view JSON classes that Omeka's API pipeline depended on were removed in laminas-view 3.0. Making the pipeline freestanding removes all direct Omeka S references to the package and clears a blocker for an eventual upgrade to laminas-view 3.0. --- application/Module.php | 10 ++---- application/src/View/Model/ApiJsonModel.php | 6 ++-- .../src/View/Renderer/ApiJsonRenderer.php | 31 ++++++++++++++----- .../src/View/Strategy/ApiJsonStrategy.php | 17 ++++++++-- .../View/Renderer/ApiJsonRendererTest.php | 5 ++- composer.json | 1 - 6 files changed, 46 insertions(+), 24 deletions(-) diff --git a/application/Module.php b/application/Module.php index 178265f8d1..404dac540e 100644 --- a/application/Module.php +++ b/application/Module.php @@ -14,7 +14,6 @@ use Laminas\EventManager\Event as LaminasEvent; use Laminas\EventManager\SharedEventManagerInterface; use Laminas\Form\Element; -use Laminas\Json\Json; use Laminas\View\Renderer\PhpRenderer; /** @@ -855,11 +854,6 @@ public function serializeApiOutputJsonLd(LaminasEvent $event) $output = $args['jsonLd']; } - if (null !== $model->getOption('pretty_print')) { - // Pretty print the JSON. - $output = Json::prettyPrint($output); - } - $jsonpCallback = (string) $model->getOption('callback'); if (!empty($jsonpCallback)) { // Wrap the JSON in a JSONP callback. Normally this would be done @@ -891,7 +885,7 @@ public function serializeApiOutputRdf(LaminasEvent $event) $serializeRdf = function ($jsonLd) use ($format) { $graph = new Graph; - $graph->parse(Json::encode($jsonLd), 'jsonld'); + $graph->parse(json_encode($jsonLd), 'jsonld'); return $graph->serialise($format); }; @@ -904,7 +898,7 @@ public function serializeApiOutputRdf(LaminasEvent $event) $eventManager->trigger('api.context', null, $args); $context = $args['context']; } - $jsonLd = Json::decode(Json::encode($representation), true); + $jsonLd = json_decode(json_encode($representation), true); $jsonLd['@context'] = $context; return $jsonLd; }; diff --git a/application/src/View/Model/ApiJsonModel.php b/application/src/View/Model/ApiJsonModel.php index fa0c5e51ba..5ff6dafe6b 100644 --- a/application/src/View/Model/ApiJsonModel.php +++ b/application/src/View/Model/ApiJsonModel.php @@ -2,13 +2,15 @@ namespace Omeka\View\Model; -use Laminas\View\Model\JsonModel; +use Laminas\View\Model\ViewModel; /** * View model for JSON responses from the API. */ -class ApiJsonModel extends JsonModel +class ApiJsonModel extends ViewModel { + protected $terminate = true; + /** * Key that stores the API response in the view variables */ diff --git a/application/src/View/Renderer/ApiJsonRenderer.php b/application/src/View/Renderer/ApiJsonRenderer.php index 605c6445cd..1a27b84441 100644 --- a/application/src/View/Renderer/ApiJsonRenderer.php +++ b/application/src/View/Renderer/ApiJsonRenderer.php @@ -4,13 +4,11 @@ use Omeka\Api\Exception\ValidationException; use Omeka\Api\Response; use Laminas\EventManager\EventManager; -use Laminas\Json\Json; -use Laminas\View\Renderer\JsonRenderer; +use Laminas\View\Renderer\RendererInterface; +use Laminas\View\Renderer\TreeRendererInterface; +use Laminas\View\Resolver\ResolverInterface; -/** - * JSON renderer for API responses. - */ -class ApiJsonRenderer extends JsonRenderer +class ApiJsonRenderer implements RendererInterface, TreeRendererInterface { /** * @var bool @@ -29,6 +27,20 @@ public function __construct(EventManager $eventManager) $this->eventManager = $eventManager; } + public function getEngine() + { + return $this; + } + + public function setResolver(ResolverInterface $resolver) + { + } + + public function canRenderTrees() + { + return true; + } + /** * Return whether the response is JSONP * @@ -68,7 +80,12 @@ public function render($model, $values = null) return null; } - $output = parent::render($payload); + // HEX flags preserved from the previous Laminas\Json\Json::encode() path for output compatibility. + $flags = JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP; + if ($model->getOption('pretty_print')) { + $flags |= JSON_PRETTY_PRINT; + } + $output = json_encode($payload, $flags); // Allow modules to return custom output. $args = $this->eventManager->prepareArgs([ diff --git a/application/src/View/Strategy/ApiJsonStrategy.php b/application/src/View/Strategy/ApiJsonStrategy.php index e951ccc749..df4b12e124 100644 --- a/application/src/View/Strategy/ApiJsonStrategy.php +++ b/application/src/View/Strategy/ApiJsonStrategy.php @@ -8,15 +8,17 @@ use Omeka\Mvc\Exception as MvcException; use Omeka\View\Model\ApiJsonModel; use Omeka\View\Renderer\ApiJsonRenderer; +use Laminas\EventManager\AbstractListenerAggregate; use Laminas\EventManager\EventManager; -use Laminas\View\Strategy\JsonStrategy; +use Laminas\EventManager\EventManagerInterface; use Laminas\View\ViewEvent; /** * View strategy for returning JSON from the API. */ -class ApiJsonStrategy extends JsonStrategy +class ApiJsonStrategy extends AbstractListenerAggregate { + protected $renderer; /** * Output formats and their media types. */ @@ -42,6 +44,12 @@ public function __construct(ApiJsonRenderer $renderer, EventManager $eventManage $this->eventManager = $eventManager; } + public function attach(EventManagerInterface $events, $priority = 1) + { + $this->listeners[] = $events->attach(ViewEvent::EVENT_RENDERER, [$this, 'selectRenderer'], $priority); + $this->listeners[] = $events->attach(ViewEvent::EVENT_RESPONSE, [$this, 'injectResponse'], $priority); + } + public function selectRenderer(ViewEvent $e) { $model = $e->getModel(); @@ -66,7 +74,10 @@ public function injectResponse(ViewEvent $e) return; } - parent::injectResponse($e); + $result = $e->getResult(); + if (is_string($result)) { + $e->getResponse()->setContent($result); + } $model = $e->getModel(); $e->getResponse()->setStatusCode($this->getResponseStatusCode($model)); diff --git a/application/test/OmekaTest/View/Renderer/ApiJsonRendererTest.php b/application/test/OmekaTest/View/Renderer/ApiJsonRendererTest.php index e4273d0f3e..7f4d415313 100644 --- a/application/test/OmekaTest/View/Renderer/ApiJsonRendererTest.php +++ b/application/test/OmekaTest/View/Renderer/ApiJsonRendererTest.php @@ -3,7 +3,6 @@ use Omeka\Api\Exception\ValidationException; use Omeka\View\Renderer\ApiJsonRenderer; -use Laminas\Json\Json; use Omeka\Stdlib\ErrorStore; use Omeka\Test\TestCase; @@ -35,7 +34,7 @@ public function testRendererUsesApiResponse() ->will($this->returnValue($response)); $renderer = new ApiJsonRenderer($this->eventManager); - $this->assertEquals(Json::encode($testValue), $renderer->render($model)); + $this->assertEquals(json_encode($testValue, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP), $renderer->render($model)); } public function testRendererPassesOnNullResponse() @@ -70,6 +69,6 @@ public function testRendererShowsErrors() ->will($this->returnValue($exception)); $renderer = new ApiJsonRenderer($this->eventManager); - $this->assertEquals(Json::encode(['errors' => ['foo' => ['bar']]]), $renderer->render($model)); + $this->assertEquals(json_encode(['errors' => ['foo' => ['bar']]], JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP), $renderer->render($model)); } } diff --git a/composer.json b/composer.json index a4834663ef..46ce3c3695 100644 --- a/composer.json +++ b/composer.json @@ -27,7 +27,6 @@ "laminas/laminas-i18n": "^2.7.3", "laminas/laminas-i18n-resources": "^2.5.2", "laminas/laminas-inputfilter": "^2.7.2", - "laminas/laminas-json": "^3.0", "laminas/laminas-math": "^3.0", "laminas/laminas-modulemanager": "^2.11.0", "laminas/laminas-mvc-i18n": "^1.0", From c9b909d06f6c9f72b2a1f558678439460bbac5d5 Mon Sep 17 00:00:00 2001 From: Jim Safley Date: Fri, 22 May 2026 21:15:37 -0400 Subject: [PATCH 2/4] Fix ApiJsonStrategyTest content type assertion --- .../test/OmekaTest/View/Strategy/ApiJsonStrategyTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/test/OmekaTest/View/Strategy/ApiJsonStrategyTest.php b/application/test/OmekaTest/View/Strategy/ApiJsonStrategyTest.php index 7109d9eabb..f0107074e6 100644 --- a/application/test/OmekaTest/View/Strategy/ApiJsonStrategyTest.php +++ b/application/test/OmekaTest/View/Strategy/ApiJsonStrategyTest.php @@ -109,7 +109,7 @@ public function testStrategySetsContentType() $this->strategy->injectResponse($this->event); $headers = $this->event->getResponse()->getHeaders(); - $expectedContentType = 'application/json; charset=utf-8'; + $expectedContentType = 'application/ld+json'; $this->assertEquals($expectedContentType, $headers->get('Content-Type')->getFieldValue()); } } From a5b78cb4dbf7f02ae5c21b0e70661f3d3817b6b9 Mon Sep 17 00:00:00 2001 From: Jim Safley Date: Wed, 27 May 2026 10:31:18 -0400 Subject: [PATCH 3/4] Fix JSONP Content-Type in ApiJsonStrategy The original JsonStrategy parent call set application/javascript before the strategy appended the format-specific type. Since the last header wins in HTTP, the JSONP Content-Type was never actually sent. Set text/javascript explicitly when the renderer has a JSONP callback. --- .../src/View/Strategy/ApiJsonStrategy.php | 6 +++++- .../View/Strategy/ApiJsonStrategyTest.php | 21 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/application/src/View/Strategy/ApiJsonStrategy.php b/application/src/View/Strategy/ApiJsonStrategy.php index df4b12e124..97434df40e 100644 --- a/application/src/View/Strategy/ApiJsonStrategy.php +++ b/application/src/View/Strategy/ApiJsonStrategy.php @@ -84,7 +84,11 @@ public function injectResponse(ViewEvent $e) $e->getResponse()->getHeaders()->addHeaderLine('Omeka-S-Version', Module::VERSION); // Add the correct Content-Type header for the output format. - $e->getResponse()->getHeaders()->addHeaderLine('Content-Type', $this->formats[$this->getFormat($model)]); + if ($this->renderer->hasJsonpCallback()) { + $e->getResponse()->getHeaders()->addHeaderLine('Content-Type', 'text/javascript'); + } else { + $e->getResponse()->getHeaders()->addHeaderLine('Content-Type', $this->formats[$this->getFormat($model)]); + } } /** diff --git a/application/test/OmekaTest/View/Strategy/ApiJsonStrategyTest.php b/application/test/OmekaTest/View/Strategy/ApiJsonStrategyTest.php index f0107074e6..69ea2178d1 100644 --- a/application/test/OmekaTest/View/Strategy/ApiJsonStrategyTest.php +++ b/application/test/OmekaTest/View/Strategy/ApiJsonStrategyTest.php @@ -112,4 +112,25 @@ public function testStrategySetsContentType() $expectedContentType = 'application/ld+json'; $this->assertEquals($expectedContentType, $headers->get('Content-Type')->getFieldValue()); } + + public function testStrategySetsJsonpContentType() + { + $apiResponse = $this->createMock('Omeka\Api\Response'); + + $model = $this->createMock('Omeka\View\Model\ApiJsonModel'); + $model->expects($this->once()) + ->method('getApiResponse') + ->will($this->returnValue($apiResponse)); + + $this->renderer->expects($this->any()) + ->method('hasJsonpCallback') + ->will($this->returnValue(true)); + + $this->event->setModel($model); + $this->event->setRenderer($this->renderer); + $this->strategy->injectResponse($this->event); + + $headers = $this->event->getResponse()->getHeaders(); + $this->assertEquals('text/javascript', $headers->get('Content-Type')->getFieldValue()); + } } From 3461cc490f2ecc5a86f92f623dcf4955c109f33c Mon Sep 17 00:00:00 2001 From: Jim Safley Date: Wed, 27 May 2026 12:18:39 -0400 Subject: [PATCH 4/4] Improve docblocks and add type declarations to API JSON pipeline classes --- application/src/View/Model/ApiJsonModel.php | 23 +++++---- .../src/View/Renderer/ApiJsonRenderer.php | 28 +++++++---- .../src/View/Strategy/ApiJsonStrategy.php | 47 ++++++++++--------- 3 files changed, 59 insertions(+), 39 deletions(-) diff --git a/application/src/View/Model/ApiJsonModel.php b/application/src/View/Model/ApiJsonModel.php index 5ff6dafe6b..cf940d082f 100644 --- a/application/src/View/Model/ApiJsonModel.php +++ b/application/src/View/Model/ApiJsonModel.php @@ -2,13 +2,20 @@ namespace Omeka\View\Model; +use Exception; use Laminas\View\Model\ViewModel; /** * View model for JSON responses from the API. + * + * Carries the API response object and any thrown exception through the + * view layer to the renderer. Set as terminal to prevent layout wrapping. */ class ApiJsonModel extends ViewModel { + /** + * Terminate rendering after this model to prevent layout wrapping. + */ protected $terminate = true; /** @@ -27,7 +34,7 @@ class ApiJsonModel extends ViewModel * The API response object can be passed here directly as the first * argument. * - * @param \Omeka\Api\Response $apiResponse API response object + * @param \Omeka\Api\Response|array|null $apiResponse API response object * @param array|\Traversable $options */ public function __construct($apiResponse = null, $options = null) @@ -39,7 +46,7 @@ public function __construct($apiResponse = null, $options = null) /** * Get the API response object stored on the model. * - * @return \Omeka\Api\Response + * @return \Omeka\Api\Response|array|null */ public function getApiResponse() { @@ -49,29 +56,25 @@ public function getApiResponse() /** * Set the API response object on this model. * - * @param \Omeka\Api\Response $apiResponse + * @param \Omeka\Api\Response|array|null $apiResponse */ - public function setApiResponse($apiResponse) + public function setApiResponse($apiResponse): void { $this->setVariable(self::API_RESPONSE_KEY, $apiResponse); } /** * Get the exception stored on the model. - * - * @return \Exception|null */ - public function getException() + public function getException(): ?Exception { return $this->getVariable(self::EXCEPTION_KEY); } /** * Set the exception on this model. - * - * @param \Exception $exception */ - public function setException(\Exception $exception) + public function setException(Exception $exception): void { $this->setVariable(self::EXCEPTION_KEY, $exception); } diff --git a/application/src/View/Renderer/ApiJsonRenderer.php b/application/src/View/Renderer/ApiJsonRenderer.php index 1a27b84441..62b93d0176 100644 --- a/application/src/View/Renderer/ApiJsonRenderer.php +++ b/application/src/View/Renderer/ApiJsonRenderer.php @@ -1,6 +1,7 @@ hasJsonpCallback; } - public function setHasJsonpCallback(bool $hasJsonpCallback) + public function setHasJsonpCallback(bool $hasJsonpCallback): void { $this->hasJsonpCallback = $hasJsonpCallback; } + /** + * Render an API response as JSON. + * + * Returns null for empty (204 No Content) responses. + * + * @param \Omeka\View\Model\ApiJsonModel $model + * @return string|null + */ public function render($model, $values = null) { $response = $model->getApiResponse(); @@ -70,7 +84,7 @@ public function render($model, $values = null) } elseif ($exception instanceof ValidationException) { $errors = $exception->getErrorStore()->getErrors(); $payload = ['errors' => $errors]; - } elseif ($exception instanceof \Exception) { + } elseif ($exception instanceof Exception) { $payload = ['errors' => ['error' => $exception->getMessage()]]; } else { $payload = $response; @@ -100,10 +114,8 @@ public function render($model, $values = null) /** * Set an alternate output format. - * - * @param string $format */ - public function setFormat($format) + public function setFormat(string $format): void { $this->format = $format; } diff --git a/application/src/View/Strategy/ApiJsonStrategy.php b/application/src/View/Strategy/ApiJsonStrategy.php index 97434df40e..1a4fe6735d 100644 --- a/application/src/View/Strategy/ApiJsonStrategy.php +++ b/application/src/View/Strategy/ApiJsonStrategy.php @@ -8,6 +8,7 @@ use Omeka\Mvc\Exception as MvcException; use Omeka\View\Model\ApiJsonModel; use Omeka\View\Renderer\ApiJsonRenderer; +use Exception; use Laminas\EventManager\AbstractListenerAggregate; use Laminas\EventManager\EventManager; use Laminas\EventManager\EventManagerInterface; @@ -15,6 +16,10 @@ /** * View strategy for returning JSON from the API. + * + * Selects the renderer for ApiJsonModel requests and injects the response + * with the rendered content, HTTP status code, Omeka-S-Version header, + * and the correct Content-Type for the output format. */ class ApiJsonStrategy extends AbstractListenerAggregate { @@ -34,9 +39,6 @@ class ApiJsonStrategy extends AbstractListenerAggregate /** * Constructor, sets the renderer object - * - * @param ApiJsonRenderer - * @param EventManager */ public function __construct(ApiJsonRenderer $renderer, EventManager $eventManager) { @@ -44,12 +46,18 @@ public function __construct(ApiJsonRenderer $renderer, EventManager $eventManage $this->eventManager = $eventManager; } + /** + * Attach listeners for renderer selection and response injection. + */ public function attach(EventManagerInterface $events, $priority = 1) { $this->listeners[] = $events->attach(ViewEvent::EVENT_RENDERER, [$this, 'selectRenderer'], $priority); $this->listeners[] = $events->attach(ViewEvent::EVENT_RESPONSE, [$this, 'injectResponse'], $priority); } + /** + * Return our renderer if the model is an ApiJsonModel, otherwise do nothing. + */ public function selectRenderer(ViewEvent $e) { $model = $e->getModel(); @@ -64,6 +72,9 @@ public function selectRenderer(ViewEvent $e) return $this->renderer; } + /** + * Inject the response with content, status code, and headers. + */ public function injectResponse(ViewEvent $e) { // Test this again here to avoid running our extra code for non-API @@ -75,29 +86,29 @@ public function injectResponse(ViewEvent $e) } $result = $e->getResult(); + $response = $e->getResponse(); + $headers = $response->getHeaders(); + if (is_string($result)) { - $e->getResponse()->setContent($result); + $response->setContent($result); } $model = $e->getModel(); - $e->getResponse()->setStatusCode($this->getResponseStatusCode($model)); - $e->getResponse()->getHeaders()->addHeaderLine('Omeka-S-Version', Module::VERSION); + $response->setStatusCode($this->getResponseStatusCode($model)); + $headers->addHeaderLine('Omeka-S-Version', Module::VERSION); // Add the correct Content-Type header for the output format. if ($this->renderer->hasJsonpCallback()) { - $e->getResponse()->getHeaders()->addHeaderLine('Content-Type', 'text/javascript'); + $headers->addHeaderLine('Content-Type', 'text/javascript'); } else { - $e->getResponse()->getHeaders()->addHeaderLine('Content-Type', $this->formats[$this->getFormat($model)]); + $headers->addHeaderLine('Content-Type', $this->formats[$this->getFormat($model)]); } } /** * Get the HTTP status code for an API response. - * - * @param \Omeka\View\Model\ApiJsonModel $response - * @return int */ - protected function getResponseStatusCode(ApiJsonModel $model) + protected function getResponseStatusCode(ApiJsonModel $model): int { $response = $model->getApiResponse(); $exception = $model->getException(); @@ -107,7 +118,7 @@ protected function getResponseStatusCode(ApiJsonModel $model) return 204; // No Content } return 200; // OK - } elseif ($exception instanceof \Exception) { + } elseif ($exception instanceof Exception) { return $this->getStatusCodeForException($exception); } else { return 200; @@ -116,11 +127,8 @@ protected function getResponseStatusCode(ApiJsonModel $model) /** * Get a status code based on the type of an exception (or lack thereof). - * - * @param \Exception|null $exception - * @return int */ - protected function getStatusCodeForException(?\Exception $exception = null) + protected function getStatusCodeForException(?Exception $exception = null): int { if ($exception instanceof MvcException\InvalidJsonException) { return 400; // Bad Request @@ -142,11 +150,8 @@ protected function getStatusCodeForException(?\Exception $exception = null) /** * Get the recognized output format. - * - * @param ApiJsonModel $model - * @return string|null */ - protected function getFormat(ApiJsonModel $model) + protected function getFormat(ApiJsonModel $model): string { // Allow modules to register formats. $args = $this->eventManager->prepareArgs(['formats' => $this->formats]);