Skip to content

Commit 05bb3d8

Browse files
committed
Add API endpoint
- add API endpoint to retrieve content using the cursor pagination - add tests for the `CombinedRetrieveApi` - make `CombinedRetrieveApi` actually returning combined content
1 parent 8664500 commit 05bb3d8

File tree

7 files changed

+431
-14
lines changed

7 files changed

+431
-14
lines changed

config/mbin_routes/combined_api.yaml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,17 @@
1+
api_combined_cursor:
2+
controller: App\Controller\Api\Combined\CombinedRetrieveApi::cursorCollection
3+
path: /api/combined/2.0
4+
methods: [ GET ]
5+
format: json
6+
7+
api_combined_user_cursor:
8+
controller: App\Controller\Api\Combined\CombinedRetrieveApi::cursorUserCollection
9+
path: /api/combined/2.0/{contentType}
10+
requirements:
11+
contentType: subscribed|moderated|favourited
12+
methods: [ PUT ]
13+
format: json
14+
115
api_combined:
216
controller: App\Controller\Api\Combined\CombinedRetrieveApi::collection
317
path: /api/combined

src/Controller/Api/BaseApi.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
use App\Factory\PostFactory;
4242
use App\Factory\UserFactory;
4343
use App\Form\Constraint\ImageConstraint;
44+
use App\Pagination\Cursor\CursorPaginationInterface;
4445
use App\Repository\BookmarkListRepository;
4546
use App\Repository\BookmarkRepository;
4647
use App\Repository\Criteria;
@@ -55,6 +56,7 @@
5556
use App\Repository\ReputationRepository;
5657
use App\Repository\TagLinkRepository;
5758
use App\Repository\UserRepository;
59+
use App\Schema\CursorPaginationSchema;
5860
use App\Schema\PaginationSchema;
5961
use App\Service\BookmarkManager;
6062
use App\Service\InstanceManager;
@@ -231,6 +233,14 @@ public function serializePaginated(array $serializedItems, PagerfantaInterface $
231233
];
232234
}
233235

236+
public function serializeCursorPaginated(array $serializedItems, CursorPaginationInterface $pagerfanta): array
237+
{
238+
return [
239+
'items' => $serializedItems,
240+
'pagination' => new CursorPaginationSchema($pagerfanta),
241+
];
242+
}
243+
234244
public function serializeContentInterface(ContentInterface $content, bool $forceVisible = false): mixed
235245
{
236246
$toReturn = null;

src/Controller/Api/Combined/CombinedRetrieveApi.php

Lines changed: 265 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,16 @@
1111
use App\Entity\Post;
1212
use App\Entity\User;
1313
use App\PageView\ContentPageView;
14+
use App\Pagination\Cursor\CursorPaginationInterface;
1415
use App\Repository\ContentRepository;
1516
use App\Repository\Criteria;
17+
use App\Schema\CursorPaginationSchema;
1618
use App\Schema\Errors\TooManyRequestsErrorSchema;
1719
use App\Schema\Errors\UnauthorizedErrorSchema;
1820
use App\Schema\PaginationSchema;
1921
use Nelmio\ApiDocBundle\Attribute\Model;
2022
use OpenApi\Attributes as OA;
23+
use Pagerfanta\PagerfantaInterface;
2124
use Symfony\Bundle\SecurityBundle\Security;
2225
use Symfony\Component\HttpFoundation\JsonResponse;
2326
use Symfony\Component\HttpKernel\Attribute\MapQueryParameter;
@@ -125,7 +128,12 @@ public function collection(
125128
#[MapQueryParameter] ?string $time,
126129
#[MapQueryParameter] ?string $federation,
127130
): JsonResponse {
128-
return $this->generateResponse($apiReadLimiter, $anonymousApiReadLimiter, $p, $security, $sort, $time, $federation, $perPage, $contentRepository);
131+
$headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter);
132+
$criteria = $this->getCriteria($p, $security, $sort, $time, $federation, $perPage, $contentRepository, null);
133+
134+
$content = $contentRepository->findByCriteria($criteria);
135+
136+
return $this->serializeContent($content, $headers);
129137
}
130138

131139
#[OA\Response(
@@ -228,28 +236,237 @@ public function userCollection(
228236
#[MapQueryParameter] ?string $federation,
229237
string $collectionType,
230238
): JsonResponse {
231-
return $this->generateResponse($apiReadLimiter, $anonymousApiReadLimiter, $p, $security, $sort, $time, $federation, $perPage, $contentRepository, $collectionType);
239+
$headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter);
240+
$criteria = $this->getCriteria($p, $security, $sort, $time, $federation, $perPage, $contentRepository, $collectionType);
241+
242+
$content = $contentRepository->findByCriteria($criteria);
243+
244+
return $this->serializeContent($content, $headers);
232245
}
233246

234-
private function generateResponse(
247+
#[OA\Response(
248+
response: 200,
249+
description: 'A cursor paginated list of combined entries and posts filtered by the query parameters',
250+
headers: [
251+
new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')),
252+
new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')),
253+
new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')),
254+
],
255+
content: new OA\JsonContent(
256+
properties: [
257+
new OA\Property(
258+
property: 'items',
259+
type: 'array',
260+
items: new OA\Items(ref: new Model(type: ContentResponseDto::class))
261+
),
262+
new OA\Property(
263+
property: 'pagination',
264+
ref: new Model(type: CursorPaginationSchema::class)
265+
),
266+
],
267+
type: 'object'
268+
)
269+
)]
270+
#[OA\Response(
271+
response: 401,
272+
description: 'Permission denied due to missing or expired token',
273+
content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class))
274+
)]
275+
#[OA\Response(
276+
response: 429,
277+
description: 'You are being rate limited',
278+
headers: [
279+
new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')),
280+
new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')),
281+
new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')),
282+
],
283+
content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class))
284+
)]
285+
#[OA\Parameter(
286+
name: 'cursor',
287+
description: 'The cursor',
288+
in: 'query',
289+
schema: new OA\Schema(type: 'string', default: null)
290+
)]
291+
#[OA\Parameter(
292+
name: 'perPage',
293+
description: 'Number of content items to retrieve per page',
294+
in: 'query',
295+
schema: new OA\Schema(type: 'integer', default: ContentRepository::PER_PAGE, maximum: self::MAX_PER_PAGE, minimum: self::MIN_PER_PAGE)
296+
)]
297+
#[OA\Parameter(
298+
name: 'sort',
299+
description: 'Sort method to use when retrieving content',
300+
in: 'query',
301+
schema: new OA\Schema(type: 'string', default: Criteria::SORT_HOT, enum: Criteria::SORT_OPTIONS)
302+
)]
303+
#[OA\Parameter(
304+
name: 'time',
305+
description: 'Max age of retrieved content',
306+
in: 'query',
307+
schema: new OA\Schema(type: 'string', default: Criteria::TIME_ALL, enum: Criteria::TIME_ROUTES_EN)
308+
)]
309+
#[OA\Parameter(
310+
name: 'lang[]',
311+
description: 'Language(s) of content to return',
312+
in: 'query',
313+
schema: new OA\Schema(
314+
type: 'array',
315+
items: new OA\Items(type: 'string', default: null, maxLength: 3, minLength: 2)
316+
),
317+
explode: true,
318+
allowReserved: true
319+
)]
320+
#[OA\Parameter(
321+
name: 'usePreferredLangs',
322+
description: 'Filter by a user\'s preferred languages? (Requires authentication and takes precedence over lang[])',
323+
in: 'query',
324+
schema: new OA\Schema(type: 'boolean', default: false),
325+
)]
326+
#[OA\Parameter(
327+
name: 'federation',
328+
description: 'What type of federated entries to retrieve',
329+
in: 'query',
330+
schema: new OA\Schema(type: 'string', default: Criteria::AP_ALL, enum: Criteria::AP_OPTIONS)
331+
)]
332+
#[OA\Tag(name: 'combined')]
333+
public function cursorCollection(
334+
RateLimiterFactory $apiReadLimiter,
335+
RateLimiterFactory $anonymousApiReadLimiter,
336+
Security $security,
337+
ContentRepository $contentRepository,
338+
#[MapQueryParameter] ?string $cursor,
339+
#[MapQueryParameter] ?int $perPage,
340+
#[MapQueryParameter] ?string $sort,
341+
#[MapQueryParameter] ?string $time,
342+
#[MapQueryParameter] ?string $federation,
343+
): JsonResponse {
344+
$headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter);
345+
$criteria = $this->getCriteria(1, $security, $sort, $time, $federation, $perPage, $contentRepository, null);
346+
$currentCursor = $this->getCursor($contentRepository, $criteria, $cursor);
347+
348+
$content = $contentRepository->findByCriteriaCursored($criteria, $currentCursor);
349+
350+
return $this->serializeContentCursored($content, $headers);
351+
}
352+
353+
#[OA\Response(
354+
response: 200,
355+
description: 'A cursor paginated list of combined entries and posts from subscribed magazines and users filtered by the query parameters',
356+
headers: [
357+
new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')),
358+
new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')),
359+
new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')),
360+
],
361+
content: new OA\JsonContent(
362+
properties: [
363+
new OA\Property(
364+
property: 'items',
365+
type: 'array',
366+
items: new OA\Items(ref: new Model(type: ContentResponseDto::class))
367+
),
368+
new OA\Property(
369+
property: 'pagination',
370+
ref: new Model(type: CursorPaginationSchema::class)
371+
),
372+
],
373+
type: 'object'
374+
)
375+
)]
376+
#[OA\Response(
377+
response: 401,
378+
description: 'Permission denied due to missing or expired token',
379+
content: new OA\JsonContent(ref: new Model(type: UnauthorizedErrorSchema::class))
380+
)]
381+
#[OA\Response(
382+
response: 429,
383+
description: 'You are being rate limited',
384+
headers: [
385+
new OA\Header(header: 'X-RateLimit-Remaining', description: 'Number of requests left until you will be rate limited', schema: new OA\Schema(type: 'integer')),
386+
new OA\Header(header: 'X-RateLimit-Retry-After', description: 'Unix timestamp to retry the request after', schema: new OA\Schema(type: 'integer')),
387+
new OA\Header(header: 'X-RateLimit-Limit', description: 'Number of requests available', schema: new OA\Schema(type: 'integer')),
388+
],
389+
content: new OA\JsonContent(ref: new Model(type: TooManyRequestsErrorSchema::class))
390+
)]
391+
#[OA\Parameter(
392+
name: 'cursor',
393+
description: 'The cursor',
394+
in: 'query',
395+
schema: new OA\Schema(type: 'string', default: null)
396+
)]
397+
#[OA\Parameter(
398+
name: 'perPage',
399+
description: 'Number of content items to retrieve per page',
400+
in: 'query',
401+
schema: new OA\Schema(type: 'integer', default: ContentRepository::PER_PAGE, maximum: self::MAX_PER_PAGE, minimum: self::MIN_PER_PAGE)
402+
)]
403+
#[OA\Parameter(
404+
name: 'sort',
405+
description: 'Sort method to use when retrieving content',
406+
in: 'query',
407+
schema: new OA\Schema(type: 'string', default: Criteria::SORT_HOT, enum: Criteria::SORT_OPTIONS)
408+
)]
409+
#[OA\Parameter(
410+
name: 'time',
411+
description: 'Max age of retrieved content',
412+
in: 'query',
413+
schema: new OA\Schema(type: 'string', default: Criteria::TIME_ALL, enum: Criteria::TIME_ROUTES_EN)
414+
)]
415+
#[OA\Parameter(
416+
name: 'lang[]',
417+
description: 'Language(s) of content to return',
418+
in: 'query',
419+
schema: new OA\Schema(
420+
type: 'array',
421+
items: new OA\Items(type: 'string', default: null, maxLength: 3, minLength: 2)
422+
),
423+
explode: true,
424+
allowReserved: true
425+
)]
426+
#[OA\Parameter(
427+
name: 'usePreferredLangs',
428+
description: 'Filter by a user\'s preferred languages? (Requires authentication and takes precedence over lang[])',
429+
in: 'query',
430+
schema: new OA\Schema(type: 'boolean', default: false),
431+
)]
432+
#[OA\Parameter(
433+
name: 'federation',
434+
description: 'What type of federated entries to retrieve',
435+
in: 'query',
436+
schema: new OA\Schema(type: 'string', default: Criteria::AP_ALL, enum: Criteria::AP_OPTIONS)
437+
)]
438+
#[OA\Tag(name: 'combined')]
439+
#[\Nelmio\ApiDocBundle\Attribute\Security(name: 'oauth2', scopes: ['read'])]
440+
#[IsGranted('ROLE_OAUTH2_READ')]
441+
public function cursorUserCollection(
235442
RateLimiterFactory $apiReadLimiter,
236443
RateLimiterFactory $anonymousApiReadLimiter,
237-
?int $p,
238444
Security $security,
239-
?string $sort,
240-
?string $time,
241-
?string $federation,
242-
?int $perPage,
243445
ContentRepository $contentRepository,
244-
?string $collectionType = null,
446+
#[MapQueryParameter] ?string $cursor,
447+
#[MapQueryParameter] ?int $perPage,
448+
#[MapQueryParameter] ?string $sort,
449+
#[MapQueryParameter] ?string $time,
450+
#[MapQueryParameter] ?string $federation,
451+
string $collectionType,
245452
): JsonResponse {
246453
$headers = $this->rateLimit($apiReadLimiter, $anonymousApiReadLimiter);
454+
$criteria = $this->getCriteria(1, $security, $sort, $time, $federation, $perPage, $contentRepository, $collectionType);
455+
$currentCursor = $this->getCursor($contentRepository, $criteria, $cursor);
456+
457+
$content = $contentRepository->findByCriteriaCursored($criteria, $currentCursor);
458+
459+
return $this->serializeContentCursored($content, $headers);
460+
}
461+
462+
private function getCriteria(?int $p, Security $security, ?string $sort, ?string $time, ?string $federation, ?int $perPage, ContentRepository $contentRepository, ?string $collectionType): ContentPageView
463+
{
247464
$criteria = new ContentPageView($p ?? 1, $security);
248465
$criteria->sortOption = $sort ?? Criteria::SORT_HOT;
249466
$criteria->time = $criteria->resolveTime($time ?? Criteria::TIME_ALL);
250467
$criteria->setFederation($federation ?? Criteria::AP_ALL);
251468
$this->handleLanguageCriteria($criteria);
252-
$criteria->content = Criteria::CONTENT_THREADS;
469+
$criteria->content = Criteria::CONTENT_COMBINED;
253470
$criteria->perPage = $perPage;
254471
$user = $security->getUser();
255472
if ($user instanceof User) {
@@ -268,11 +485,13 @@ private function generateResponse(
268485
break;
269486
}
270487

271-
$content = $contentRepository->findByCriteria($criteria);
272-
$this->handleLanguageCriteria($criteria);
488+
return $criteria;
489+
}
273490

491+
private function serializeContent(PagerfantaInterface $content, array $headers): JsonResponse
492+
{
274493
$result = [];
275-
foreach ($content->getCurrentPageResults() as $item) {
494+
foreach ($content as $item) {
276495
if ($item instanceof Entry) {
277496
$this->handlePrivateContent($item);
278497
$result[] = new ContentResponseDto(entry: $this->serializeEntry($this->entryFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item)));
@@ -284,4 +503,37 @@ private function generateResponse(
284503

285504
return new JsonResponse($this->serializePaginated($result, $content), headers: $headers);
286505
}
506+
507+
private function serializeContentCursored(CursorPaginationInterface $content, array $headers): JsonResponse
508+
{
509+
$result = [];
510+
foreach ($content as $item) {
511+
if ($item instanceof Entry) {
512+
$this->handlePrivateContent($item);
513+
$result[] = new ContentResponseDto(entry: $this->serializeEntry($this->entryFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item)));
514+
} elseif ($item instanceof Post) {
515+
$this->handlePrivateContent($item);
516+
$result[] = new ContentResponseDto(post: $this->serializePost($this->postFactory->createDto($item), $this->tagLinkRepository->getTagsOfContent($item)));
517+
}
518+
}
519+
520+
return new JsonResponse($this->serializeCursorPaginated($result, $content), headers: $headers);
521+
}
522+
523+
/**
524+
* @throws \DateMalformedStringException
525+
*/
526+
private function getCursor(ContentRepository $contentRepository, ContentPageView $criteria, ?string $cursor): int|\DateTime|\DateTimeImmutable
527+
{
528+
$initialCursor = $contentRepository->guessInitialCursor($criteria);
529+
if ($initialCursor instanceof \DateTime || $initialCursor instanceof \DateTimeImmutable) {
530+
$currentCursor = null !== $cursor ? new \DateTimeImmutable($cursor) : $initialCursor;
531+
} elseif (\is_int($initialCursor)) {
532+
$currentCursor = null !== $cursor ? \intval($cursor) : $initialCursor;
533+
} else {
534+
throw new \LogicException(\get_class($initialCursor).' is not accounted for');
535+
}
536+
537+
return $currentCursor;
538+
}
287539
}

0 commit comments

Comments
 (0)