diff --git a/UPGRADING.md b/UPGRADING.md index 034e2d0b6..75218dfc6 100644 --- a/UPGRADING.md +++ b/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) diff --git a/src/API/ActivityController.php b/src/API/ActivityController.php index b1b652ffb..641a343c0 100644 --- a/src/API/ActivityController.php +++ b/src/API/ActivityController.php @@ -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); diff --git a/src/API/BaseApiController.php b/src/API/BaseApiController.php index 490904cc0..f2d7f5938 100644 --- a/src/API/BaseApiController.php +++ b/src/API/BaseApiController.php @@ -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; } } diff --git a/src/API/CustomerController.php b/src/API/CustomerController.php index 6daf608e2..b7e27b597 100644 --- a/src/API/CustomerController.php +++ b/src/API/CustomerController.php @@ -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); diff --git a/src/API/ProjectController.php b/src/API/ProjectController.php index ef57aa05a..715f19d0b 100644 --- a/src/API/ProjectController.php +++ b/src/API/ProjectController.php @@ -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); diff --git a/src/API/TimesheetController.php b/src/API/TimesheetController.php index 9952a64a8..a72132597 100644 --- a/src/API/TimesheetController.php +++ b/src/API/TimesheetController.php @@ -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 $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') { diff --git a/src/API/UserController.php b/src/API/UserController.php index a14743869..7aa6db48e 100644 --- a/src/API/UserController.php +++ b/src/API/UserController.php @@ -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') { diff --git a/src/Repository/ProjectRepository.php b/src/Repository/ProjectRepository.php index 824ce5846..ec8ce6608 100644 --- a/src/Repository/ProjectRepository.php +++ b/src/Repository/ProjectRepository.php @@ -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); diff --git a/tests/Utils/PaginationTest.php b/tests/Utils/PaginationTest.php index d4ab94865..9bc18cdcf 100644 --- a/tests/Utils/PaginationTest.php +++ b/tests/Utils/PaginationTest.php @@ -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()); }