Skip to content

Commit 3b535a3

Browse files
committed
feat(api): added pagination, sorting, and filtering for API controllers (#3830)
1 parent 6cb6fd9 commit 3b535a3

14 files changed

+991
-253
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ This is a log of major user-visible changes in each phpMyFAQ release.
1212
- added Symfony Router for frontend (Thorsten)
1313
- added API for glossary definitions (Thorsten)
1414
- added admin log CSV export feature (Thorsten)
15+
- added pagination, sorting, and filtering for APIs (Thorsten)
1516
- improved audit and activity log with comprehensive security event tracking (Thorsten)
1617
- improved API errors with formatted RFC 7807 Problem Details JSON responses (Thorsten)
1718
- migrated codebase to use PHP 8.4 language features (Thorsten)

phpmyfaq/src/phpMyFAQ/Comment/CommentsRepository.php

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,22 +43,110 @@ public function fetchByReferenceIdAndType(int $referenceId, string $type): array
4343
%sfaqcomments
4444
WHERE
4545
type = '%s'
46-
AND
46+
AND
47+
id = %d
48+
SQL;
49+
50+
$query = sprintf(
51+
$sql,
52+
Database::getTablePrefix(),
53+
$this->coreConfiguration->getDb()->escape($type),
54+
$referenceId,
55+
);
56+
57+
$result = $this->coreConfiguration->getDb()->query($query);
58+
$rows = $this->coreConfiguration->getDb()->fetchAll($result);
59+
return is_array($rows) ? $rows : [];
60+
}
61+
62+
/**
63+
* Fetch comments with pagination and sorting
64+
*
65+
* @param int $referenceId Record ID
66+
* @param string $type Type (faq or news)
67+
* @param int $limit Items per page
68+
* @param int $offset Offset for pagination
69+
* @param string $sortField Field to sort by
70+
* @param string $sortOrder Sort order (ASC or DESC)
71+
* @return array<int, object>
72+
*/
73+
public function fetchPaginated(
74+
int $referenceId,
75+
string $type,
76+
int $limit,
77+
int $offset,
78+
string $sortField = 'id_comment',
79+
string $sortOrder = 'ASC',
80+
): array {
81+
$allowedSortFields = ['id_comment', 'id', 'usr', 'email', 'datum'];
82+
$sortField = in_array($sortField, $allowedSortFields) ? $sortField : 'id_comment';
83+
$sortOrder = strtoupper($sortOrder) === 'DESC' ? 'DESC' : 'ASC';
84+
85+
$sql = <<<SQL
86+
SELECT
87+
id_comment, id, usr, email, comment, datum
88+
FROM
89+
%sfaqcomments
90+
WHERE
91+
type = '%s'
92+
AND
4793
id = %d
94+
ORDER BY
95+
%s %s
96+
LIMIT %d OFFSET %d
4897
SQL;
4998

5099
$query = sprintf(
51100
$sql,
52101
Database::getTablePrefix(),
53102
$this->coreConfiguration->getDb()->escape($type),
54103
$referenceId,
104+
$sortField,
105+
$sortOrder,
106+
$limit,
107+
$offset,
55108
);
56109

57110
$result = $this->coreConfiguration->getDb()->query($query);
58111
$rows = $this->coreConfiguration->getDb()->fetchAll($result);
59112
return is_array($rows) ? $rows : [];
60113
}
61114

115+
/**
116+
* Count total comments for a reference ID and type
117+
*
118+
* @param int $referenceId Record ID
119+
* @param string $type Type (faq or news)
120+
* @return int
121+
*/
122+
public function countByReferenceIdAndType(int $referenceId, string $type): int
123+
{
124+
$sql = <<<SQL
125+
SELECT
126+
COUNT(*) AS total
127+
FROM
128+
%sfaqcomments
129+
WHERE
130+
type = '%s'
131+
AND
132+
id = %d
133+
SQL;
134+
135+
$query = sprintf(
136+
$sql,
137+
Database::getTablePrefix(),
138+
$this->coreConfiguration->getDb()->escape($type),
139+
$referenceId,
140+
);
141+
142+
$result = $this->coreConfiguration->getDb()->query($query);
143+
if ($row = $this->coreConfiguration->getDb()->fetchObject($result)) {
144+
return (int) $row->total;
145+
}
146+
147+
return 0;
148+
}
149+
62150
public function insert(Comment $comment): bool
63151
{
64152
$helpedValue = $comment->hasHelped();

phpmyfaq/src/phpMyFAQ/Comments.php

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,58 @@ public function getCommentsData(int $referenceId, string $type): array
6969
return $comments;
7070
}
7171

72+
/**
73+
* Returns paginated user comments from a record by type.
74+
*
75+
* @param int $referenceId Record ID
76+
* @param string $type Record type: {faq|news}
77+
* @param int $limit Items per page
78+
* @param int $offset Offset for pagination
79+
* @param string $sortField Field to sort by
80+
* @param string $sortOrder Sort order (ASC or DESC)
81+
*
82+
* @return Comment[]
83+
*/
84+
public function getCommentsDataPaginated(
85+
int $referenceId,
86+
string $type,
87+
int $limit,
88+
int $offset,
89+
string $sortField = 'id_comment',
90+
string $sortOrder = 'ASC',
91+
): array {
92+
$comments = [];
93+
94+
$rows = $this->commentsRepository->fetchPaginated($referenceId, $type, $limit, $offset, $sortField, $sortOrder);
95+
96+
foreach ($rows as $row) {
97+
$comment = new Comment();
98+
$comment
99+
->setId((int) $row->id_comment)
100+
->setRecordId((int) $row->id)
101+
->setComment($row->comment)
102+
->setDate(Date::createIsoDateFromUnixTimestamp($row->datum, DateTimeInterface::ATOM))
103+
->setUsername($row->usr)
104+
->setEmail($row->email)
105+
->setType($type);
106+
$comments[] = $comment;
107+
}
108+
109+
return $comments;
110+
}
111+
112+
/**
113+
* Count total comments for a reference ID and type.
114+
*
115+
* @param int $referenceId Record ID
116+
* @param string $type Record type: {faq|news}
117+
* @return int
118+
*/
119+
public function countComments(int $referenceId, string $type): int
120+
{
121+
return $this->commentsRepository->countByReferenceIdAndType($referenceId, $type);
122+
}
123+
72124
/**
73125
* Adds a new comment.
74126
*/

phpmyfaq/src/phpMyFAQ/Controller/Api/CommentController.php

Lines changed: 106 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -22,31 +22,21 @@
2222
use Exception;
2323
use OpenApi\Attributes as OA;
2424
use phpMyFAQ\Comments;
25-
use phpMyFAQ\Controller\AbstractController;
2625
use phpMyFAQ\Entity\CommentType;
2726
use phpMyFAQ\Filter;
2827
use Symfony\Component\HttpFoundation\JsonResponse;
2928
use Symfony\Component\HttpFoundation\Request;
3029
use Symfony\Component\HttpFoundation\Response;
31-
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
3230

33-
final class CommentController extends AbstractController
31+
final class CommentController extends AbstractApiController
3432
{
35-
public function __construct()
36-
{
37-
parent::__construct();
38-
39-
if (!$this->isApiEnabled()) {
40-
throw new UnauthorizedHttpException(challenge: 'API is not enabled');
41-
}
42-
}
43-
4433
/**
4534
* @throws Exception
46-
*/ #[OA\Get(
35+
*/
36+
#[OA\Get(
4737
path: '/api/v3.2/comments/{faqId}',
4838
operationId: 'getComments',
49-
description: 'Returns a list of comments for a given FAQ record ID.',
39+
description: 'Returns a paginated list of comments for a given FAQ record ID.',
5040
tags: ['Public Endpoints'],
5141
)]
5242
#[OA\Header(
@@ -61,34 +51,116 @@ public function __construct()
6151
required: true,
6252
schema: new OA\Schema(type: 'integer'),
6353
)]
64-
#[OA\Response(response: 200, description: 'If the FAQ has at least one comment.', content: new OA\JsonContent(
65-
example: '
66-
[
67-
{
68-
"id": 2,
69-
"recordId": 142,
70-
"categoryId": null,
71-
"type": "faq",
72-
"username": "phpMyFAQ User",
73-
"email": "[email protected]",
74-
"comment": "Foo! Bar?",
75-
"date": "2019-12-24T12:24:57+0100",
76-
"helped": null
54+
#[OA\Parameter(
55+
name: 'page',
56+
description: 'Page number for pagination (page-based)',
57+
in: 'query',
58+
required: false,
59+
schema: new OA\Schema(type: 'integer', default: 1),
60+
)]
61+
#[OA\Parameter(
62+
name: 'per_page',
63+
description: 'Items per page (page-based, max 100)',
64+
in: 'query',
65+
required: false,
66+
schema: new OA\Schema(type: 'integer', default: 25),
67+
)]
68+
#[OA\Parameter(
69+
name: 'limit',
70+
description: 'Number of items to return (offset-based, max 100)',
71+
in: 'query',
72+
required: false,
73+
schema: new OA\Schema(type: 'integer', default: 25),
74+
)]
75+
#[OA\Parameter(
76+
name: 'offset',
77+
description: 'Starting offset (offset-based)',
78+
in: 'query',
79+
required: false,
80+
schema: new OA\Schema(type: 'integer', default: 0),
81+
)]
82+
#[OA\Parameter(name: 'sort', description: 'Field to sort by', in: 'query', required: false, schema: new OA\Schema(
83+
type: 'string',
84+
default: 'id_comment',
85+
enum: ['id_comment', 'id', 'usr', 'email', 'datum'],
86+
))]
87+
#[OA\Parameter(
88+
name: 'order',
89+
description: 'Sort direction',
90+
in: 'query',
91+
required: false,
92+
schema: new OA\Schema(type: 'string', default: 'asc', enum: ['asc', 'desc']),
93+
)]
94+
#[OA\Response(response: 200, description: 'Returns paginated comments for the FAQ.', content: new OA\JsonContent(
95+
example: '{
96+
"success": true,
97+
"data": [
98+
{
99+
"id": 2,
100+
"recordId": 142,
101+
"categoryId": null,
102+
"type": "faq",
103+
"username": "phpMyFAQ User",
104+
"email": "[email protected]",
105+
"comment": "Foo! Bar?",
106+
"date": "2019-12-24T12:24:57+0100",
107+
"helped": null
108+
}
109+
],
110+
"meta": {
111+
"pagination": {
112+
"total": 50,
113+
"count": 25,
114+
"per_page": 25,
115+
"current_page": 1,
116+
"total_pages": 2,
117+
"links": {
118+
"first": "/api/v3.2/comments/142?page=1&per_page=25",
119+
"last": "/api/v3.2/comments/142?page=2&per_page=25",
120+
"prev": null,
121+
"next": "/api/v3.2/comments/142?page=2&per_page=25"
122+
}
123+
},
124+
"sorting": {
125+
"field": "id_comment",
126+
"order": "asc"
127+
}
77128
}
78-
]',
129+
}',
79130
))]
80-
#[OA\Response(response: 404, description: 'If the FAQ has no comments.', content: new OA\JsonContent(example: []))]
131+
#[OA\Response(
132+
response: 200,
133+
description: 'If no comments are found, returns empty data array.',
134+
content: new OA\JsonContent(example: '{"success": true, "data": []}'),
135+
)]
81136
public function list(Request $request): JsonResponse
82137
{
83138
$recordId = (int) Filter::filterVar($request->attributes->get(key: 'recordId'), FILTER_VALIDATE_INT);
84139

85140
/** @var Comments $comments */
86141
$comments = $this->container->get(id: 'phpmyfaq.comments');
87-
$result = $comments->getCommentsData($recordId, CommentType::FAQ);
88-
if ((is_countable($result) ? count($result) : 0) === 0) {
89-
$this->json($result, Response::HTTP_NOT_FOUND);
90-
}
91142

92-
return $this->json($result, Response::HTTP_OK);
143+
// Get pagination and sorting parameters
144+
$pagination = $this->getPaginationRequest();
145+
$sort = $this->getSortRequest(
146+
allowedFields: ['id_comment', 'id', 'usr', 'email', 'datum'],
147+
defaultField: 'id_comment',
148+
defaultOrder: 'asc',
149+
);
150+
151+
// Get paginated comments
152+
$result = $comments->getCommentsDataPaginated(
153+
referenceId: $recordId,
154+
type: CommentType::FAQ,
155+
limit: $pagination->limit,
156+
offset: $pagination->offset,
157+
sortField: $sort->getField() ?? 'id_comment',
158+
sortOrder: $sort->getOrderSql(),
159+
);
160+
161+
// Get total count
162+
$total = $comments->countComments($recordId, CommentType::FAQ);
163+
164+
return $this->paginatedResponse(data: $result, total: $total, pagination: $pagination, sort: $sort);
93165
}
94166
}

0 commit comments

Comments
 (0)