Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature request: pagination support in JsonModel #12

Open
weierophinney opened this issue Dec 31, 2019 · 9 comments
Open

Feature request: pagination support in JsonModel #12

weierophinney opened this issue Dec 31, 2019 · 9 comments
Labels
Enhancement New feature or request

Comments

@weierophinney
Copy link
Contributor

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 zfcampus/zf-content-negotiation#24

@weierophinney weierophinney added the Enhancement New feature or request label Dec 31, 2019
@weierophinney
Copy link
Contributor Author

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


Originally posted by @TomHAnderson at zfcampus/zf-content-negotiation#24 (comment)

@weierophinney
Copy link
Contributor Author

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 zfcampus/zf-content-negotiation#24 (comment)

@weierophinney
Copy link
Contributor Author

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 zfcampus/zf-content-negotiation#24 (comment)

@weierophinney
Copy link
Contributor Author

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 zfcampus/zf-content-negotiation#24 (comment)

@ezkimo
Copy link

ezkimo commented Nov 12, 2021

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
Copy link
Member

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

ezkimo commented Nov 12, 2021

@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
Copy link
Member

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

ezkimo commented Nov 12, 2021

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Enhancement New feature or request
Projects
None yet
Development

No branches or pull requests

3 participants