1111use App \Entity \Post ;
1212use App \Entity \User ;
1313use App \PageView \ContentPageView ;
14+ use App \Pagination \Cursor \CursorPaginationInterface ;
1415use App \Repository \ContentRepository ;
1516use App \Repository \Criteria ;
17+ use App \Schema \CursorPaginationSchema ;
1618use App \Schema \Errors \TooManyRequestsErrorSchema ;
1719use App \Schema \Errors \UnauthorizedErrorSchema ;
1820use App \Schema \PaginationSchema ;
1921use Nelmio \ApiDocBundle \Attribute \Model ;
2022use OpenApi \Attributes as OA ;
23+ use Pagerfanta \PagerfantaInterface ;
2124use Symfony \Bundle \SecurityBundle \Security ;
2225use Symfony \Component \HttpFoundation \JsonResponse ;
2326use 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