global pagination support for API collection calls
This commit is contained in:
12
UPGRADING.md
12
UPGRADING.md
@@ -8,6 +8,18 @@ you can upgrade your Kimai installation to the latest stable release.
|
||||
Check below if there are more version specific steps required, which need to be executed after the normal update process.
|
||||
Perform EACH version specific task between your version and the new one, otherwise you risk data inconsistency or a broken installation.
|
||||
|
||||
## [2.14.0](https://github.com/kimai/kimai/releases/tag/2.13.0)
|
||||
|
||||
**DEVELOPERS ONLY**
|
||||
|
||||
The API now uses pagination globally for all collection endpoints. This might break current implementations
|
||||
|
||||
All collections are limited to 50 entries by default (changes in Customer, Project, Activity, User).
|
||||
|
||||
The parameters to influence pagination, page size and page number are `size` (and `pageSize`) and `page`.
|
||||
|
||||
Read the [API pagination docs](https://www.kimai.org/documentation/api-pagination.html) for more infos.
|
||||
|
||||
## [2.0.30](https://github.com/kimai/kimai/releases/tag/2.0.30)
|
||||
|
||||
The `DATABASE_URL` in your environment settings ([.env](https://github.com/kimai/kimai/issues/4246) or in your [Docker](https://github.com/tobybatch/kimai2/issues/531) or webserver config)
|
||||
|
||||
@@ -44,10 +44,10 @@ final class ActivityController extends BaseApiController
|
||||
public const GROUPS_RATE = ['Default', 'Entity', 'Activity_Rate'];
|
||||
|
||||
public function __construct(
|
||||
private ViewHandlerInterface $viewHandler,
|
||||
private ActivityRepository $repository,
|
||||
private EventDispatcherInterface $dispatcher,
|
||||
private ActivityRateRepository $activityRateRepository
|
||||
private readonly ViewHandlerInterface $viewHandler,
|
||||
private readonly ActivityRepository $repository,
|
||||
private readonly EventDispatcherInterface $dispatcher,
|
||||
private readonly ActivityRateRepository $activityRateRepository
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -64,14 +64,13 @@ final class ActivityController extends BaseApiController
|
||||
#[Rest\QueryParam(name: 'globals', strict: true, nullable: true, description: 'Use if you want to fetch only global activities. Allowed values: true (default: false)')]
|
||||
#[Rest\QueryParam(name: 'orderBy', requirements: 'id|name|project', strict: true, nullable: true, description: 'The field by which results will be ordered. Allowed values: id, name, project (default: name)')]
|
||||
#[Rest\QueryParam(name: 'order', requirements: 'ASC|DESC', strict: true, nullable: true, description: 'The result order. Allowed values: ASC, DESC (default: ASC)')]
|
||||
#[Rest\QueryParam(name: 'term', description: 'Free search term')]
|
||||
#[Rest\QueryParam(name: 'term', nullable: true, description: 'Free search term')]
|
||||
#[Rest\QueryParam(name: 'page', requirements: '\d+', strict: true, nullable: true, description: 'The page to display, renders a 404 if not found (default: 1)')]
|
||||
#[Rest\QueryParam(name: 'size', requirements: '\d+', strict: true, nullable: true, description: 'The amount of entries for each page (default: 50)')]
|
||||
public function cgetAction(ParamFetcherInterface $paramFetcher, ProjectRepository $projectRepository): Response
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
|
||||
$query = new ActivityQuery();
|
||||
$query->setCurrentUser($user);
|
||||
$this->prepareBaseQuery($query, $paramFetcher);
|
||||
|
||||
$order = $paramFetcher->get('order');
|
||||
if (\is_string($order) && $order !== '') {
|
||||
@@ -112,9 +111,9 @@ final class ActivityController extends BaseApiController
|
||||
$query->setSearchTerm(new SearchTerm($term));
|
||||
}
|
||||
|
||||
$query->setIsApiCall(true);
|
||||
$data = $this->repository->getActivitiesForQuery($query);
|
||||
$view = new View($data, 200);
|
||||
$pagination = $this->repository->getPagerfantaForQuery($query);
|
||||
$view = $this->createViewForPagination($pagination);
|
||||
|
||||
$view->getContext()->setGroups(self::GROUPS_COLLECTION);
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
|
||||
@@ -13,6 +13,7 @@ use App\Entity\User;
|
||||
use App\Repository\Query\BaseQuery;
|
||||
use App\Timesheet\DateTimeFactory;
|
||||
use App\Utils\Pagination;
|
||||
use FOS\RestBundle\Request\ParamFetcherInterface;
|
||||
use FOS\RestBundle\View\View;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
|
||||
@@ -52,11 +53,45 @@ abstract class BaseApiController extends AbstractController
|
||||
return DateTimeFactory::createByUser($user);
|
||||
}
|
||||
|
||||
protected function addPagination(View $view, Pagination $pagination): void
|
||||
protected function prepareBaseQuery(BaseQuery $query, ParamFetcherInterface $paramFetcher): void
|
||||
{
|
||||
$query->setIsApiCall(true);
|
||||
$query->setCurrentUser($this->getUser());
|
||||
|
||||
$all = $paramFetcher->all(true);
|
||||
if (array_key_exists('page', $all)) {
|
||||
$page = $all['page'];
|
||||
if (\is_string($page) && $page !== '') {
|
||||
$query->setPage((int) $page);
|
||||
}
|
||||
}
|
||||
|
||||
if (array_key_exists('size', $all)) {
|
||||
$size = $all['size'];
|
||||
if (\is_string($size) && $size !== '') {
|
||||
$query->setPageSize((int)$size);
|
||||
}
|
||||
}
|
||||
|
||||
if (array_key_exists('pageSize', $all)) {
|
||||
$size = $all['pageSize'];
|
||||
if (\is_string($size) && $size !== '') {
|
||||
$query->setPageSize((int)$size);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected function createViewForPagination(Pagination $pagination): View
|
||||
{
|
||||
$results = (array) $pagination->getCurrentPageResults();
|
||||
|
||||
$view = new View($results, 200);
|
||||
|
||||
$view->setHeader('X-Page', (string) $pagination->getCurrentPage());
|
||||
$view->setHeader('X-Total-Count', (string) $pagination->getNbResults());
|
||||
$view->setHeader('X-Total-Pages', (string) $pagination->getNbPages());
|
||||
$view->setHeader('X-Per-Page', (string) $pagination->getMaxPerPage());
|
||||
|
||||
return $view;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,10 +44,10 @@ final class CustomerController extends BaseApiController
|
||||
public const GROUPS_RATE = ['Default', 'Entity', 'Customer_Rate'];
|
||||
|
||||
public function __construct(
|
||||
private ViewHandlerInterface $viewHandler,
|
||||
private CustomerRepository $repository,
|
||||
private EventDispatcherInterface $dispatcher,
|
||||
private CustomerRateRepository $customerRateRepository
|
||||
private readonly ViewHandlerInterface $viewHandler,
|
||||
private readonly CustomerRepository $repository,
|
||||
private readonly EventDispatcherInterface $dispatcher,
|
||||
private readonly CustomerRateRepository $customerRateRepository
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -61,14 +61,13 @@ final class CustomerController extends BaseApiController
|
||||
#[Rest\QueryParam(name: 'visible', requirements: '1|2|3', default: 1, strict: true, nullable: true, description: 'Visibility status to filter customers: 1=visible, 2=hidden, 3=both')]
|
||||
#[Rest\QueryParam(name: 'order', requirements: 'ASC|DESC', strict: true, nullable: true, description: 'The result order. Allowed values: ASC, DESC (default: ASC)')]
|
||||
#[Rest\QueryParam(name: 'orderBy', requirements: 'id|name', strict: true, nullable: true, description: 'The field by which results will be ordered. Allowed values: id, name (default: name)')]
|
||||
#[Rest\QueryParam(name: 'term', description: 'Free search term')]
|
||||
#[Rest\QueryParam(name: 'term', nullable: true, description: 'Free search term')]
|
||||
#[Rest\QueryParam(name: 'page', requirements: '\d+', strict: true, nullable: true, description: 'The page to display, renders a 404 if not found (default: 1)')]
|
||||
#[Rest\QueryParam(name: 'size', requirements: '\d+', strict: true, nullable: true, description: 'The amount of entries for each page (default: 50)')]
|
||||
public function cgetAction(ParamFetcherInterface $paramFetcher): Response
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
|
||||
$query = new CustomerQuery();
|
||||
$query->setCurrentUser($user);
|
||||
$this->prepareBaseQuery($query, $paramFetcher);
|
||||
|
||||
$order = $paramFetcher->get('order');
|
||||
if (\is_string($order) && $order !== '') {
|
||||
@@ -90,9 +89,9 @@ final class CustomerController extends BaseApiController
|
||||
$query->setSearchTerm(new SearchTerm($term));
|
||||
}
|
||||
|
||||
$query->setIsApiCall(true);
|
||||
$data = $this->repository->getCustomersForQuery($query);
|
||||
$view = new View($data, 200);
|
||||
$pagination = $this->repository->getPagerfantaForQuery($query);
|
||||
$view = $this->createViewForPagination($pagination);
|
||||
|
||||
$view->getContext()->setGroups(self::GROUPS_COLLECTION);
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
|
||||
@@ -46,11 +46,11 @@ final class ProjectController extends BaseApiController
|
||||
public const GROUPS_RATE = ['Default', 'Entity', 'Project_Rate'];
|
||||
|
||||
public function __construct(
|
||||
private ViewHandlerInterface $viewHandler,
|
||||
private ProjectRepository $repository,
|
||||
private EventDispatcherInterface $dispatcher,
|
||||
private ProjectRateRepository $projectRateRepository,
|
||||
private ProjectService $projectService
|
||||
private readonly ViewHandlerInterface $viewHandler,
|
||||
private readonly ProjectRepository $repository,
|
||||
private readonly EventDispatcherInterface $dispatcher,
|
||||
private readonly ProjectRateRepository $projectRateRepository,
|
||||
private readonly ProjectService $projectService
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -70,14 +70,13 @@ final class ProjectController extends BaseApiController
|
||||
#[Rest\QueryParam(name: 'globalActivities', requirements: '0|1', strict: true, nullable: true, description: "If given, filters projects by their 'global activity' support. Allowed values: 1 (supports global activities) and 0 (without global activities) (default: all)")]
|
||||
#[Rest\QueryParam(name: 'order', requirements: 'ASC|DESC', strict: true, nullable: true, description: 'The result order. Allowed values: ASC, DESC (default: ASC)')]
|
||||
#[Rest\QueryParam(name: 'orderBy', requirements: 'id|name|customer', strict: true, nullable: true, description: 'The field by which results will be ordered. Allowed values: id, name, customer (default: name)')]
|
||||
#[Rest\QueryParam(name: 'term', description: 'Free search term')]
|
||||
#[Rest\QueryParam(name: 'term', nullable: true, description: 'Free search term')]
|
||||
#[Rest\QueryParam(name: 'page', requirements: '\d+', strict: true, nullable: true, description: 'The page to display, renders a 404 if not found (default: 1)')]
|
||||
#[Rest\QueryParam(name: 'size', requirements: '\d+', strict: true, nullable: true, description: 'The amount of entries for each page (default: 50)')]
|
||||
public function cgetAction(ParamFetcherInterface $paramFetcher, CustomerRepository $customerRepository): Response
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $this->getUser();
|
||||
|
||||
$query = new ProjectQuery();
|
||||
$query->setCurrentUser($user);
|
||||
$this->prepareBaseQuery($query, $paramFetcher);
|
||||
|
||||
$order = $paramFetcher->get('order');
|
||||
if (\is_string($order) && $order !== '') {
|
||||
@@ -144,9 +143,9 @@ final class ProjectController extends BaseApiController
|
||||
$query->setSearchTerm(new SearchTerm($term));
|
||||
}
|
||||
|
||||
$query->setIsApiCall(true);
|
||||
$data = $this->repository->getProjectsForQuery($query);
|
||||
$view = new View($data, 200);
|
||||
$pagination = $this->repository->getPagerfantaForQuery($query);
|
||||
$view = $this->createViewForPagination($pagination);
|
||||
|
||||
$view->getContext()->setGroups(self::GROUPS_COLLECTION);
|
||||
|
||||
return $this->viewHandler->handle($view);
|
||||
|
||||
@@ -54,11 +54,11 @@ final class TimesheetController extends BaseApiController
|
||||
public const GROUPS_COLLECTION_FULL = ['Default', 'Collection', 'Timesheet', 'Expanded'];
|
||||
|
||||
public function __construct(
|
||||
private ViewHandlerInterface $viewHandler,
|
||||
private TimesheetRepository $repository,
|
||||
private TagRepository $tagRepository,
|
||||
private EventDispatcherInterface $dispatcher,
|
||||
private TimesheetService $service
|
||||
private readonly ViewHandlerInterface $viewHandler,
|
||||
private readonly TimesheetRepository $repository,
|
||||
private readonly TagRepository $tagRepository,
|
||||
private readonly EventDispatcherInterface $dispatcher,
|
||||
private readonly TimesheetService $service
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -94,11 +94,13 @@ final class TimesheetController extends BaseApiController
|
||||
#[Rest\QueryParam(name: 'active', requirements: '0|1', strict: true, nullable: true, description: 'Filter for running/active records. Allowed values: 0=stopped, 1=active (default: all)')]
|
||||
#[Rest\QueryParam(name: 'billable', requirements: '0|1', strict: true, nullable: true, description: 'Filter for non-/billable records. Allowed values: 0=non-billable, 1=billable (default: all)')]
|
||||
#[Rest\QueryParam(name: 'full', requirements: '0|1|true|false', strict: true, nullable: true, description: 'Allows to fetch full objects including subresources. Allowed values: 0|1|false|true (default: false)')]
|
||||
#[Rest\QueryParam(name: 'term', description: 'Free search term')]
|
||||
#[Rest\QueryParam(name: 'term', nullable: true, description: 'Free search term')]
|
||||
#[Rest\QueryParam(name: 'modified_after', requirements: [new Constraints\DateTime(format: 'Y-m-d\TH:i:s')], strict: true, nullable: true, description: 'Only records changed after this date will be included (format: HTML5). Available since Kimai 1.10 and works only for records that were created/updated since then.')]
|
||||
public function cgetAction(ParamFetcherInterface $paramFetcher, CustomerRepository $customerRepository, ProjectRepository $projectRepository, ActivityRepository $activityRepository, UserRepository $userRepository): Response
|
||||
{
|
||||
$query = new TimesheetQuery(false);
|
||||
$this->prepareBaseQuery($query, $paramFetcher);
|
||||
|
||||
$seeAll = false;
|
||||
|
||||
if ($this->isGranted('view_other_timesheet')) {
|
||||
@@ -170,16 +172,6 @@ final class TimesheetController extends BaseApiController
|
||||
$query->addActivity($activity);
|
||||
}
|
||||
|
||||
$page = $paramFetcher->get('page');
|
||||
if (\is_string($page) && $page !== '') {
|
||||
$query->setPage((int) $page);
|
||||
}
|
||||
|
||||
$size = $paramFetcher->get('size');
|
||||
if (\is_string($size) && $size !== '') {
|
||||
$query->setPageSize((int) $size);
|
||||
}
|
||||
|
||||
/** @var array<string> $tags */
|
||||
$tags = $paramFetcher->get('tags');
|
||||
if (\is_array($tags) && \count($tags) > 0) {
|
||||
@@ -250,12 +242,8 @@ final class TimesheetController extends BaseApiController
|
||||
$query->setModifiedAfter($factory->createDateTime($modifiedAfter));
|
||||
}
|
||||
|
||||
$query->setIsApiCall(true);
|
||||
$data = $this->repository->getPagerfantaForQuery($query);
|
||||
$results = (array) $data->getCurrentPageResults();
|
||||
|
||||
$view = new View($results, 200);
|
||||
$this->addPagination($view, $data);
|
||||
$pagination = $this->repository->getPagerfantaForQuery($query);
|
||||
$view = $this->createViewForPagination($pagination);
|
||||
|
||||
$full = $paramFetcher->get('full');
|
||||
if ($full === '1' || $full === 'true') {
|
||||
|
||||
@@ -42,10 +42,10 @@ final class UserController extends BaseApiController
|
||||
public const GROUPS_COLLECTION_FULL = ['Default', 'Collection', 'User', 'User_Entity'];
|
||||
|
||||
public function __construct(
|
||||
private ViewHandlerInterface $viewHandler,
|
||||
private UserRepository $repository,
|
||||
private UserPasswordHasherInterface $passwordHasher,
|
||||
private SystemConfiguration $configuration
|
||||
private readonly ViewHandlerInterface $viewHandler,
|
||||
private readonly UserRepository $repository,
|
||||
private readonly UserPasswordHasherInterface $passwordHasher,
|
||||
private readonly SystemConfiguration $configuration
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -60,12 +60,14 @@ final class UserController extends BaseApiController
|
||||
#[Rest\QueryParam(name: 'visible', requirements: '1|2|3', default: 1, strict: true, nullable: true, description: 'Visibility status to filter users: 1=visible, 2=hidden, 3=all')]
|
||||
#[Rest\QueryParam(name: 'orderBy', requirements: 'id|username|alias|email', strict: true, nullable: true, description: 'The field by which results will be ordered. Allowed values: id, username, alias, email (default: username)')]
|
||||
#[Rest\QueryParam(name: 'order', requirements: 'ASC|DESC', strict: true, nullable: true, description: 'The result order. Allowed values: ASC, DESC (default: ASC)')]
|
||||
#[Rest\QueryParam(name: 'term', description: 'Free search term')]
|
||||
#[Rest\QueryParam(name: 'term', nullable: true, description: 'Free search term')]
|
||||
#[Rest\QueryParam(name: 'full', requirements: '0|1|true|false', strict: true, nullable: true, description: 'Allows to fetch full objects including subresources. Allowed values: 0|1|false|true (default: false)')]
|
||||
#[Rest\QueryParam(name: 'page', requirements: '\d+', strict: true, nullable: true, description: 'The page to display, renders a 404 if not found (default: 1)')]
|
||||
#[Rest\QueryParam(name: 'size', requirements: '\d+', strict: true, nullable: true, description: 'The amount of entries for each page (default: 50)')]
|
||||
public function cgetAction(ParamFetcherInterface $paramFetcher): Response
|
||||
{
|
||||
$query = new UserQuery();
|
||||
$query->setCurrentUser($this->getUser());
|
||||
$this->prepareBaseQuery($query, $paramFetcher);
|
||||
|
||||
$visible = $paramFetcher->get('visible');
|
||||
if (\is_string($visible) && $visible !== '') {
|
||||
@@ -87,9 +89,8 @@ final class UserController extends BaseApiController
|
||||
$query->setSearchTerm(new SearchTerm($term));
|
||||
}
|
||||
|
||||
$query->setIsApiCall(true);
|
||||
$data = $this->repository->getUsersForQuery($query);
|
||||
$view = new View($data, 200);
|
||||
$pagination = $this->repository->getPagerfantaForQuery($query);
|
||||
$view = $this->createViewForPagination($pagination);
|
||||
|
||||
$full = $paramFetcher->get('full');
|
||||
if ($full === '1' || $full === 'true') {
|
||||
|
||||
@@ -58,31 +58,13 @@ class ProjectRepository extends EntityRepository
|
||||
return $projects;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Project $project
|
||||
* @throws ORMException
|
||||
* @throws \Doctrine\ORM\OptimisticLockException
|
||||
*/
|
||||
public function saveProject(Project $project)
|
||||
public function saveProject(Project $project): void
|
||||
{
|
||||
$entityManager = $this->getEntityManager();
|
||||
$entityManager->persist($project);
|
||||
$entityManager->flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param null|bool $visible
|
||||
* @return int
|
||||
*/
|
||||
public function countProject($visible = null): int
|
||||
{
|
||||
if (null !== $visible) {
|
||||
return $this->count(['visible' => (bool) $visible]);
|
||||
}
|
||||
|
||||
return $this->count([]);
|
||||
}
|
||||
|
||||
public function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = []): void
|
||||
{
|
||||
$permissions = $this->getPermissionCriteria($qb, $user, $teams);
|
||||
@@ -139,9 +121,6 @@ class ProjectRepository extends EntityRepository
|
||||
|
||||
/**
|
||||
* Returns a query builder that is used for ProjectType and your own 'query_builder' option.
|
||||
*
|
||||
* @param ProjectFormTypeQuery $query
|
||||
* @return QueryBuilder
|
||||
*/
|
||||
public function getQueryBuilderForFormType(ProjectFormTypeQuery $query): QueryBuilder
|
||||
{
|
||||
@@ -359,7 +338,6 @@ class ProjectRepository extends EntityRepository
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ProjectQuery $query
|
||||
* @return Project[]
|
||||
*/
|
||||
public function getProjectsForQuery(ProjectQuery $query): iterable
|
||||
@@ -372,12 +350,7 @@ class ProjectRepository extends EntityRepository
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Project $delete
|
||||
* @param Project|null $replace
|
||||
* @throws \Doctrine\ORM\Exception\ORMException
|
||||
*/
|
||||
public function deleteProject(Project $delete, ?Project $replace = null)
|
||||
public function deleteProject(Project $delete, ?Project $replace = null): void
|
||||
{
|
||||
$em = $this->getEntityManager();
|
||||
$em->beginTransaction();
|
||||
@@ -429,14 +402,14 @@ class ProjectRepository extends EntityRepository
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
public function saveComment(ProjectComment $comment)
|
||||
public function saveComment(ProjectComment $comment): void
|
||||
{
|
||||
$entityManager = $this->getEntityManager();
|
||||
$entityManager->persist($comment);
|
||||
$entityManager->flush();
|
||||
}
|
||||
|
||||
public function deleteComment(ProjectComment $comment)
|
||||
public function deleteComment(ProjectComment $comment): void
|
||||
{
|
||||
$entityManager = $this->getEntityManager();
|
||||
$entityManager->remove($comment);
|
||||
|
||||
@@ -41,9 +41,8 @@ class PaginationTest extends TestCase
|
||||
$query = new TimesheetQuery();
|
||||
$query->setPage(3);
|
||||
$query->setPageSize(1);
|
||||
$query->setIsApiCall(true);
|
||||
$sut = new Pagination(new ArrayAdapter([1, 2, 3, 4, 5]), $query);
|
||||
$this->assertFalse($sut->getNormalizeOutOfRangePages());
|
||||
$this->assertTrue($sut->getNormalizeOutOfRangePages());
|
||||
$this->assertEquals(1, $sut->getMaxPerPage());
|
||||
$this->assertEquals(3, $sut->getCurrentPage());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user