json-api-server/src/Endpoint/Index.php

204 lines
6.7 KiB
PHP

<?php
/*
* This file is part of tobyz/json-api-server.
*
* (c) Toby Zerner <toby.zerner@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Tobyz\JsonApiServer\Endpoint;
use JsonApiPhp\JsonApi as Structure;
use JsonApiPhp\JsonApi\Link\LastLink;
use JsonApiPhp\JsonApi\Link\NextLink;
use JsonApiPhp\JsonApi\Link\PrevLink;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface as Request;
use Tobyz\JsonApiServer\Context;
use Tobyz\JsonApiServer\Endpoint\Concerns\BuildsMeta;
use Tobyz\JsonApiServer\Endpoint\Concerns\IncludesData;
use Tobyz\JsonApiServer\Exception\BadRequestException;
use Tobyz\JsonApiServer\Exception\ForbiddenException;
use Tobyz\JsonApiServer\ResourceType;
use Tobyz\JsonApiServer\Serializer;
use function Tobyz\JsonApiServer\evaluate;
use function Tobyz\JsonApiServer\json_api_response;
use function Tobyz\JsonApiServer\run_callbacks;
class Index
{
use IncludesData;
use BuildsMeta;
/**
* Handle a request to show a resource listing.
*/
public function handle(Context $context, ResourceType $resourceType): ResponseInterface
{
$adapter = $resourceType->getAdapter();
$schema = $resourceType->getSchema();
if (! evaluate($schema->isListable(), [$context])) {
throw new ForbiddenException(sprintf(
'Cannot list resource type %s',
$resourceType->getType()
));
}
$query = $adapter->query();
$resourceType->applyScopes($query, $context);
$include = $this->getInclude($context, $resourceType);
[$offset, $limit] = $this->paginate($resourceType, $query, $context);
if ($sortString = $context->getRequest()->getQueryParams()['sort'] ?? $schema->getDefaultSort()) {
$resourceType->applySort($query, $sortString, $context);
}
if ($filter = $context->getRequest()->getQueryParams()['filter'] ?? null) {
if (! is_array($filter)) {
throw (new BadRequestException('filter must be an array'))->setSourceParameter('filter');
}
$resourceType->applyFilters($query, $filter, $context);
}
run_callbacks($schema->getListeners('listing'), [$query, $context]);
$total = $schema->isCountable() ? $adapter->count($query) : null;
$models = $adapter->get($query);
run_callbacks($schema->getListeners('listed'), [$models, $context]);
$serializer = new Serializer($context);
foreach ($models as $model) {
$serializer->add($resourceType, $model, $include);
}
[$primary, $included] = $serializer->serialize();
$paginationLinks = $this->buildPaginationLinks(
$resourceType,
$context->getRequest(),
$offset,
$limit,
count($models),
$total
);
$meta = [
new Structure\Meta('offset', $offset),
new Structure\Meta('limit', $limit),
];
if ($total !== null) {
$meta[] = new Structure\Meta('total', $total);
}
$meta = array_merge($meta, $this->buildMeta($context));
return json_api_response(
new Structure\CompoundDocument(
new Structure\PaginatedCollection(
new Structure\Pagination(...$paginationLinks),
new Structure\ResourceCollection(...$primary)
),
new Structure\Included(...$included),
new Structure\Link\SelfLink($this->buildUrl($context->getRequest())),
...$meta
)
);
}
private function buildUrl(Request $request, array $overrideParams = []): string
{
[$selfUrl] = explode('?', $request->getUri(), 2);
$queryParams = array_replace_recursive($request->getQueryParams(), $overrideParams);
if (isset($queryParams['page']['offset']) && $queryParams['page']['offset'] <= 0) {
unset($queryParams['page']['offset']);
}
if (isset($queryParams['filter'])) {
foreach ($queryParams['filter'] as $k => &$v) {
$v = $v === null ? '' : $v;
}
}
$queryString = http_build_query($queryParams, '', '&', PHP_QUERY_RFC3986);
return $selfUrl.($queryString ? '?'.$queryString : '');
}
private function buildPaginationLinks(ResourceType $resourceType, Request $request, int $offset, ?int $limit, int $count, ?int $total): array
{
$paginationLinks = [];
$schema = $resourceType->getSchema();
if ($offset > 0) {
$paginationLinks[] = new Structure\Link\FirstLink($this->buildUrl($request, ['page' => ['offset' => 0]]));
$prevOffset = $offset - $limit;
if ($prevOffset < 0) {
$params = ['page' => ['offset' => 0, 'limit' => $offset]];
} else {
$params = ['page' => ['offset' => max(0, $prevOffset)]];
}
$paginationLinks[] = new PrevLink($this->buildUrl($request, $params));
}
if ($schema->isCountable() && $schema->getPerPage() && $limit && $offset + $limit < $total) {
$paginationLinks[] = new LastLink($this->buildUrl($request, ['page' => ['offset' => floor(($total - 1) / $limit) * $limit]]));
}
if (($total === null && $count === $limit) || $offset + $count < $total) {
$paginationLinks[] = new NextLink($this->buildUrl($request, ['page' => ['offset' => $offset + $limit]]));
}
return $paginationLinks;
}
private function paginate(ResourceType $resourceType, $query, Context $context): array
{
$schema = $resourceType->getSchema();
$queryParams = $context->getRequest()->getQueryParams();
$limit = $schema->getPerPage();
if (isset($queryParams['page']['limit'])) {
$limit = $queryParams['page']['limit'];
if (! ctype_digit(strval($limit)) || $limit < 1) {
throw (new BadRequestException('page[limit] must be a positive integer'))->setSourceParameter('page[limit]');
}
$limit = min($schema->getLimit(), $limit);
}
$offset = 0;
if (isset($queryParams['page']['offset'])) {
$offset = $queryParams['page']['offset'];
if (! ctype_digit(strval($offset)) || $offset < 0) {
throw (new BadRequestException('page[offset] must be a non-negative integer'))->setSourceParameter('page[offset]');
}
}
if ($limit || $offset) {
$resourceType->getAdapter()->paginate($query, $limit, $offset);
}
return [$offset, $limit];
}
}