laminas-api-tools / api-tools-content-negotiation

Laminas Module providing content-negotiation features
https://api-tools.getlaminas.org/documentation
BSD 3-Clause "New" or "Revised" License
5 stars 14 forks source link

Feature request: pagination support in JsonModel #12

Open weierophinney opened 4 years ago

weierophinney commented 4 years ago

Hi; if i use "Content Negotiation Selector": Json; the paginator is not working correctly and i always get 10 rows in collection, if i change it to HalJson it works as expecting

example:

'zf-rest' => [
   'service\\name\\...' => [
   'page_size' => 25, // with JSON it is always 10, no matter if i set it to -1 or 1000
// ...
]

Originally posted by @kukoman at https://github.com/zfcampus/zf-content-negotiation/issues/24

weierophinney commented 4 years ago

@kukoman is this still an issue? Maybe your paginator and not the page_size was set to 10?


Originally posted by @TomHAnderson at https://github.com/zfcampus/zf-content-negotiation/issues/24#issuecomment-66343819

weierophinney commented 4 years ago

yep, still the same behavior

to better explain whats going on:

// my EquipmentResource::fetchAll method
    public function fetchAll($params = array())
    {
        $adapter = new ArrayAdapter($this->getEquipmentService()->fetchAll($params));
        $collection = new EquipmentCollection($adapter);
        return $collection;
   }

// zf-rest config:

        'Mcm\\V1\\Rest\\Equipment\\Controller' => array(
            'listener' => 'Mcm\\V1\\Rest\\Equipment\\EquipmentResource',
            'route_name' => 'mcm.rest.equipment',
            'route_identifier_name' => 'equipment_id',
            'collection_name' => 'equipment',
            'entity_http_methods' => array(
                0 => 'GET',
                1 => 'PATCH',
                2 => 'PUT',
                3 => 'DELETE',
            ),
            'collection_http_methods' => array(
                0 => 'GET',
                1 => 'POST',
            ),
            'collection_query_whitelist' => array(
                0 => 'orderBy',
                1 => 'query',
                2 => 'filter',
            ),
            'page_size' => 100,
            'page_size_param' => 100,
            'entity_class' => 'Mcm\\V1\\Rest\\Equipment\\EquipmentEntity',
            'collection_class' => 'Mcm\\V1\\Rest\\Equipment\\EquipmentCollection',
            'service_name' => 'Equipment',
        ),

and still you get only 10 results

if I change it from JSON to HAL it works as expected


Originally posted by @kukoman at https://github.com/zfcampus/zf-content-negotiation/issues/24#issuecomment-66449804

weierophinney commented 4 years ago

You could set it in the collection class. This is because the collection extends from Paginator

class SomeCollection extends Paginator
{
    protected static $defaultItemCountPerPage = 10;
}

Originally posted by @agustincl at https://github.com/zfcampus/zf-content-negotiation/issues/24#issuecomment-113256370

weierophinney commented 4 years ago

The root cause is because the JsonModel does not do pagination; all it does is serialize the value presented using json_encode(). When presented with any iterator, json_encode() returns an object, with the keys being the indexes, and the value at that index of the iterator. This is true with paginators as well.

The page_size and page_size_param values are only used by zf-hal. As such, you will need to update your controller to inject the page and page size prior to returning the paginator:

$collection->setCurrentPageNumber($request->getQuery('page', 1));
$collection->setItemCountPerPage(25); // you might want to inject this value from configuration

We may add pagination support to zf-content-negotiation's JsonModel in the future, but for now, the above is how to handle it.


Originally posted by @weierophinney at https://github.com/zfcampus/zf-content-negotiation/issues/24#issuecomment-231189750

ezkimo commented 2 years ago

For those who still need pagination in JsonModel I 've realized a view strategy that follows the principles of the HalJson view strategy. The classes shown here are probably not the best way how to archive pagination attributes in the JsonModel / json content negotiation, but they work pretty well.

The View Strategy

First of all, a view strategy that only takes effect when it comes to our own view renderer, that will handle the pagination attributes.

<?php
declare(strict_types=1);
namespace Application\View\Strategy;

use Application\View\Renderer\JsonRenderer;
use Laminas\ApiTools\ApiProblem\View\ApiProblemModel;
use Laminas\ApiTools\ContentNegotiation\JsonModel;
use Laminas\View\Strategy\JsonStrategy as LaminasJsonStrategy;
use Laminas\View\ViewEvent;

class JsonStrategy extends LaminasJsonStrategy
{
    public function __construct(JsonRenderer $renderer)
    {
        $this->renderer = $renderer;
    }

    public function selectRenderer(ViewEvent $event)
    {
        $model = $event->getModel();

        if (!$model instanceof JsonModel) {
            return;
        }

        $this->renderer->setViewEvent($event);
        return $this->renderer;
    }

    public function injectResponse(ViewEvent $event)
    {
        $renderer = $event->getRenderer();
        if ($renderer !== $this->renderer) {
            return;
        }

        $result = $event->getResult();
        if (!is_string($result)) {
            return;
        }

        $model = $event->getModel();

        $response = $event->getResponse();
        $response->setContent($result);

        $headers = $response->getHeaders();
        $headers->addHeaderLine(
            'content-type', 
            $model instanceof ApiProblemModel ? 'application/problem+json' : 'application/json'
        );
    }
}

The factory for the JsonStrategy class.

<?php
declare(strict_types=1);
namespace Application\View\Strategy\Factory;

use Application\View\Renderer\JsonRenderer;
use Application\View\Strategy\JsonStrategy;
use Psr\Container\ContainerInterface;

class JsonStrategyFactory 
{
    public function __invoke(ContainerInterface $container): JsonStrategy
    {
        $renderer = $container->get(JsonRenderer::class);
        return new JsonStrategy($renderer);
    }
}

The Renderer

As you might have seen we need a renderer instance, that handles the pagination attributes. The renderer does basicly the same as the HalJsonRenderer. It 's pretty basic, because wie do not need all that hal link stuff.

<?php
declare(strict_types=1);
namespace Application\View\Renderer;

use Application\View\Helper\JsonViewHelper;
use Laminas\ApiTools\ApiProblem\ApiProblem;
use Laminas\ApiTools\ApiProblem\View\ApiProblemModel;
use Laminas\ApiTools\ApiProblem\View\ApiProblemRenderer;
use Laminas\ApiTools\ContentNegotiation\JsonModel;
use Laminas\ApiTools\Hal\Collection;
use Laminas\View\HelperPluginManager;
use Laminas\View\Renderer\JsonRenderer as LaminasJsonRenderer;
use Laminas\View\ViewEvent;

class JsonRenderer extends LaminasJsonRenderer
{
    protected ApiProblemRenderer $apiProblemRenderer;
    protected HelperPluginManager $helpers;
    protected ViewEvent $viewEvent;

    public function __construct(ApiProblemRenderer $apiProblemRenderer)
    {
        $this->apiProblemRenderer = $apiProblemRenderer;
    }

    public function getHelperPluginManager(): HelperPluginManager
    {
        if (!$this->helpers instanceof HelperPluginManager) {
            $this->setHelperPluginManager(new HelperPluginManager());
        }

        return $this->helpers;
    }

    public function setHelperPluginManager(HelperPluginManager $helpers): void
    {
        $this->helpers = $helpers;
    }

    public function getViewEvent(): ViewEvent
    {
        return $this->viewEvent;
    }

    public function setViewEvent(ViewEvent $event): void
    {
        $this->viewEvent = $event;
    }

    public function render($nameOrModel, $values = null)
    {
        if (!$nameOrModel instanceof JsonModel) {
            return parent::render($nameOrModel, $values);
        }

        $payload = $nameOrModel->getVariable('payload');
        if ($payload instanceof Collection) {
            $helper = $this->getHelperPluginManager()->get(JsonViewHelper::class);
            $payload = $helper->renderCollection($payload);

            if ($payload instanceof ApiProblem) {
                $this->renderApiProblem($payload);
            }

            return parent::render($payload);
        }

        return parent::render($nameOrModel, $values);
    }

    protected function renderApiProblem(ApiProblem $problem): string
    {
        $model = new ApiProblemModel($problem);
        $event = $this->getViewEvent();

        if ($event) {
            $event->setModel($model);
        }

        return $this->apiProblemRenderer->render($model);
    }
}

As you can see the renderer has a dependency to a view helper and the ApiProblemRenderer class. Therefore we need another factory that creates a renderer instance.

<?php
declare(strict_types=1);
namespace Application\View\Renderer\Factory;

use Application\View\Renderer\JsonRenderer;
use Laminas\ApiTools\ApiProblem\View\ApiProblemRenderer;
use Psr\Container\ContainerInterface;

class JsonRendererFactory
{
    public function __invoke(ContainerInterface $container): JsonRenderer
    {
        $helpers = $container->get('ViewHelperManager');
        $apiProblemRenderer = $container->get(ApiProblemRenderer::class);

        $renderer = new JsonRenderer($apiProblemRenderer);
        $renderer->setHelperPluginManager($helpers);

        return $renderer;
    }
}

The View Helper

The renderer class uses a view helper, that renders a collection to a json string. Basicly this view helper does all the basic things, that the HAL view helper does without wiring links and handle single entites. This view helper class is just for rendering collections as JSON string. Beside that the attributes that we know from HAL are set. Theoretically, one could do without the event manager. However, since I need it in my application, it is included here.

<?php
declare(strict_types=1);
namespace Application\View\Helper;

use ArrayObject;
use Countable;
use Laminas\ApiTools\ApiProblem\ApiProblem;
use Laminas\ApiTools\Hal\Collection;
use Laminas\EventManager\EventManagerAwareInterface;
use Laminas\EventManager\EventManagerAwareTrait;
use Laminas\Mvc\Controller\Plugin\PluginInterface;
use Laminas\Paginator\Paginator;
use Laminas\Stdlib\DispatchableInterface;
use Laminas\View\Helper\AbstractHelper;

class JsonViewHelper extends AbstractHelper implements PluginInterface, EventManagerAwareInterface
{
    use EventManagerAwareTrait;

    protected DispatchableInterface $controller;

    public function getController()
    {
        return $this->controller;
    }

    public function setController(DispatchableInterface $controller)
    {
        $this->controller = $controller;
    }

    public function renderCollection(Collection $halCollection): array
    {
        $this->getEventManager()->trigger(
            __FUNCTION__ . '.pre',
            $this,
            [ 'collection' => $halCollection ]
        );

        $payload = $halCollection->getAttributes();
        $collection = $halCollection->getCollection();

        $payload[$halCollection->getCollectionName()] = $this->extractCollection($halCollection);

        if ($collection instanceof Paginator) {
            $payload['page_count'] = $payload['page_count'] ?? $collection->count();
            $payload['total_items'] = $payload['total_items'] ?? $collection->getTotalItemCount();
            $payload['page_size'] = $payload['page_size'] ?? $halCollection->getPageSize();
            $payload['page'] = $payload['page_count'] > 0 ? $halCollection->getPage() : 0;
        } elseif (is_array($collection) || $collection instanceof Countable) {
            $payload['total_items'] = $payload['total_items'] ?? count($collection);
        }

        $payload = new ArrayObject($payload);

        $this->getEventManager()->trigger(
            __FUNCTION__ . '.post',
            $this,
            [ 'payload' => $payload, 'collection' => $halCollection ]
        );

        return $payload->getArrayCopy();
    }

    protected function extractCollection(Collection $halCollection): array
    {
        $collection = [];
        $eventManager = $this->getEventManager();

        foreach ($halCollection->getCollection() as $entity) {
            $eventParams = new ArrayObject([
                'collection' => $halCollection,
                'entity' => $entity,
                'resource' => $entity,
            ]);

            $eventManager->trigger('renderCollection.entity', $this, $eventParams);

            $collection[] = $entity;
        }

        return $collection;
    }
}

All dependencies are done in the factory. Factories FTW!

<?php
declare(strict_types=1);
namespace Application\View\Helper\Factory;

use Application\View\Helper\JsonViewHelper;
use Psr\Container\ContainerInterface;

class JsonViewHelperFactory
{
    public function __invoke(ContainerInterface $container): JsonViewHelper
    {
        $helper = new JsonViewHelper();

        if ($container->has('EventManager')) {
            $helper->setEventManager($container->get('EventManager'));
        }

        return $helper;
    }
}

Configuration

As @froschdesign said, the view strategy does not have to be hooked into the module class. It is sufficient to make the view strategy accessible in the configuration as we check for the right JsonModel class in the strategy itself.

'view_manager' => [
    'strategies' => [
        JsonStrategy::class,
    ],
],

That 's all.

froschdesign commented 2 years ago

@ezkimo

Since we deal with events we have to plug in the above shown in the Module class.

Register your strategy via the configuration and the module extension is not needed:

'view_manager' => [
    'strategies' => [
        MyViewStrategy::class,
    ],
],

https://docs.laminas.dev/laminas-view/quick-start/#creating-and-registering-alternate-rendering-and-response-strategies

ezkimo commented 2 years ago

@froschdesign

This is not possible, as long as the view strategy is only applicable to the JsonModel class of the content-negotiation module. If the strategy were not bound to the JsonModule class, the way via the configuration would of course be the preferred way. Or am I wrong?

froschdesign commented 2 years ago

@ezkimo

This is not possible, as long as the view strategy is only applicable to the JsonModel class of the content-negotiation module.

See at your own strategy:

use Laminas\ApiTools\ContentNegotiation\JsonModel;

// …

public function selectRenderer(ViewEvent $event)
{
    $model = $event->getModel();

    if (!$model instanceof JsonModel) {
        return;
    }

    $this->renderer->setViewEvent($event);
    return $this->renderer;
}
ezkimo commented 2 years ago

@froschdesign Dang! Friday! It was a long week. You 're absolutely right. I 'll edit the comment. Thanks for advice.