From 5cb8f53fb35fab4fe226a843c03cb9128a8926f5 Mon Sep 17 00:00:00 2001 From: Pavel Mareev Date: Fri, 14 Nov 2025 18:39:37 +0100 Subject: [PATCH 1/2] feat: cursor based pagination for assets and entries [CAPI-2342] --- lib/create-contentful-api.ts | 64 +++++-- lib/types/asset.ts | 14 +- lib/types/client.ts | 65 ++++++- lib/types/collection.ts | 54 ++++-- lib/types/concept-scheme.ts | 15 +- lib/types/concept.ts | 15 +- lib/types/entry.ts | 22 ++- lib/types/query/query.ts | 41 ++++- .../normalize-cursor-pagination-parameters.ts | 19 ++ .../normalize-cursor-pagination-response.ts | 35 ++++ test/integration/getAssetsWithCursor.test.ts | 80 +++++++++ test/integration/getEntriesWithCursor.test.ts | 86 +++++++++ test/types/client/getAssets.test-d.ts | 17 +- test/types/client/getEntries.test-d.ts | 167 +++++++++++++++++- test/types/queries/asset-queries.test-d.ts | 18 +- .../cursor-pagination-queries.test-d.ts | 26 +++ test/types/queries/entry-queries.test-d.ts | 23 ++- ...alize-cursor-pagination-parameters.test.ts | 53 ++++++ ...rmalize-cursor-pagination-response.test.ts | 88 +++++++++ 19 files changed, 843 insertions(+), 59 deletions(-) create mode 100644 lib/utils/normalize-cursor-pagination-parameters.ts create mode 100644 lib/utils/normalize-cursor-pagination-response.ts create mode 100644 test/integration/getAssetsWithCursor.test.ts create mode 100644 test/integration/getEntriesWithCursor.test.ts create mode 100644 test/types/queries/cursor-pagination-queries.test-d.ts create mode 100644 test/unit/utils/normalize-cursor-pagination-parameters.test.ts create mode 100644 test/unit/utils/normalize-cursor-pagination-response.test.ts diff --git a/lib/create-contentful-api.ts b/lib/create-contentful-api.ts index 88bb94fa4..b4b2a77cd 100644 --- a/lib/create-contentful-api.ts +++ b/lib/create-contentful-api.ts @@ -29,6 +29,10 @@ import type { Concept, ConceptScheme, ConceptSchemeCollection, + AssetCursorPaginatedCollection, + CollectionForQuery, + EntryCursorPaginatedCollection, + Entry, } from './types/index.js' import normalizeSearchParameters from './utils/normalize-search-parameters.js' import normalizeSelect from './utils/normalize-select.js' @@ -44,6 +48,8 @@ import { } from './utils/validate-params.js' import validateSearchParameters from './utils/validate-search-parameters.js' import { getTimelinePreviewParams } from './utils/timeline-preview-helpers.js' +import { normalizeCursorPaginationParameters } from './utils/normalize-cursor-pagination-parameters.js' +import { normalizeCursorPaginationResponse } from './utils/normalize-cursor-pagination-response.js' const ASSET_KEY_MAX_LIFETIME = 48 * 60 * 60 @@ -210,6 +216,13 @@ export default function createContentfulApi( return makeGetEntries(query, options) } + async function getEntriesWithCursor( + query = {}, + ): Promise> { + const response = await makeGetEntries(normalizeCursorPaginationParameters(query), options) + return normalizeCursorPaginationResponse(response) + } + async function makeGetEntry( id: string, query, @@ -242,10 +255,12 @@ export default function createContentfulApi( throw notFoundError(id) } try { - const response = await internalGetEntries, Locales, Options>( - { 'sys.id': id, ...maybeEnableSourceMaps(query) }, - options, - ) + const response = await internalGetEntries< + EntrySkeletonType, + Locales, + Options, + Record + >({ 'sys.id': id, ...maybeEnableSourceMaps(query) }, options) if (response.items.length > 0) { return response.items[0] } else { @@ -256,8 +271,11 @@ export default function createContentfulApi( } } - async function makeGetEntries( - query, + async function makeGetEntries< + EntrySkeleton extends EntrySkeletonType, + Query extends Record, + >( + query: Query, options: ChainOptions = { withAllLocales: false, withoutLinkResolution: false, @@ -271,7 +289,7 @@ export default function createContentfulApi( validateRemoveUnresolvedParam(query) validateSearchParameters(query) - return internalGetEntries>( + return internalGetEntries, Query>( withAllLocales ? { ...query, @@ -293,10 +311,13 @@ export default function createContentfulApi( EntrySkeleton extends EntrySkeletonType, Locales extends LocaleCode, Options extends ChainOptions, + Query extends Record, >( - query: Record, + query: Query, options: Options, - ): Promise, Locales>> { + ): Promise< + CollectionForQuery, Locales>, Query> + > { const { withoutLinkResolution, withoutUnresolvableLinks } = options try { const entries = await get({ @@ -324,8 +345,15 @@ export default function createContentfulApi( return makeGetAssets(query, options) } - async function makeGetAssets( - query: Record, + async function getAssetsWithCursor( + query: Record = {}, + ): Promise { + const response = await makeGetAssets(normalizeCursorPaginationParameters(query), options) + return normalizeCursorPaginationResponse(response) + } + + async function makeGetAssets>( + query: Query, options: ChainOptions = { withAllLocales: false, withoutLinkResolution: false, @@ -339,7 +367,7 @@ export default function createContentfulApi( const localeSpecificQuery = withAllLocales ? { ...query, locale: '*' } : query - return internalGetAssets>(localeSpecificQuery) + return internalGetAssets, Query>(localeSpecificQuery) } async function internalGetAsset( @@ -376,9 +404,13 @@ export default function createContentfulApi( return internalGetAsset>(id, localeSpecificQuery) } - async function internalGetAssets( - query: Record, - ): Promise, Locales>> { + async function internalGetAssets< + Locales extends LocaleCode, + Options extends ChainOptions, + Query extends Record, + >( + query: Query, + ): Promise, Locales>, Query>> { try { return get({ context: 'environment', @@ -657,6 +689,7 @@ export default function createContentfulApi( getAsset, getAssets, + getAssetsWithCursor, getTag, getTags, @@ -667,6 +700,7 @@ export default function createContentfulApi( getEntry, getEntries, + getEntriesWithCursor, getConceptScheme, getConceptSchemes, diff --git a/lib/types/asset.ts b/lib/types/asset.ts index 8bb95bb7e..23ac61f57 100644 --- a/lib/types/asset.ts +++ b/lib/types/asset.ts @@ -1,4 +1,4 @@ -import type { ContentfulCollection } from './collection.js' +import type { ContentfulCollection, CursorPaginatedCollection } from './collection.js' import type { LocaleCode } from './locale.js' import type { Metadata } from './metadata.js' import type { EntitySys } from './sys.js' @@ -85,6 +85,18 @@ export type AssetCollection< Locales extends LocaleCode = LocaleCode, > = ContentfulCollection> +/** + * A cursor paginated collection of assets + * @category Asset + * @typeParam Modifiers - The chain modifiers used to configure the client. They’re set automatically when using the client chain modifiers. + * @typeParam Locales - If provided for a client using `allLocales` modifier, response type defines locale keys for asset field values. + * @see {@link https://www.contentful.com/developers/docs/references/content-delivery-api/#/reference/assets | Documentation} + */ +export type AssetCursorPaginatedCollection< + Modifiers extends ChainModifiers = ChainModifiers, + Locales extends LocaleCode = LocaleCode, +> = CursorPaginatedCollection> + /** * System managed metadata for assets * @category Asset diff --git a/lib/types/client.ts b/lib/types/client.ts index 4d90025f8..7beb6bd98 100644 --- a/lib/types/client.ts +++ b/lib/types/client.ts @@ -4,10 +4,12 @@ import type { LocaleCode, LocaleCollection } from './locale.js' import type { AssetQueries, AssetsQueries, + AssetsQueriesWithCursor, ConceptAncestorsDescendantsQueries, ConceptSchemesQueries, ConceptsQueries, EntriesQueries, + EntriesQueriesWithCursor, EntryQueries, EntrySkeletonType, TagQueries, @@ -15,8 +17,13 @@ import type { import type { SyncCollection, SyncOptions, SyncQuery } from './sync.js' import type { Tag, TagCollection } from './tag.js' import type { AssetKey } from './asset-key.js' -import type { Entry, EntryCollection } from './entry.js' -import type { Asset, AssetCollection, AssetFields } from './asset.js' +import type { Entry, EntryCollection, EntryCursorPaginatedCollection } from './entry.js' +import type { + Asset, + AssetCollection, + AssetCursorPaginatedCollection, + AssetFields, +} from './asset.js' import type { Concept, ConceptCollection } from './concept.js' import type { ConceptScheme, ConceptSchemeCollection } from './concept-scheme.js' @@ -407,6 +414,36 @@ export interface ContentfulClientApi { query?: EntriesQueries, ): Promise> + /** + * Fetches a cursor paginated collection of Entries + * @param pagination - Object with cursor pagination options + * @param query - Object with search parameters + * @returns Promise for a cursor paginated collection of Entries + * @typeParam EntrySkeleton - Shape of entry fields used to calculate dynamic keys + * @typeParam Locales - If provided for a client using `allLocales` modifier, response type defines locale keys for entry field values. + * @see {@link https://www.contentful.com/developers/docs/references/content-delivery-api/#/introduction/cursor-pagination | REST API cursor pagination reference} + * @see {@link https://www.contentful.com/developers/docs/javascript/tutorials/using-js-cda-sdk/#retrieving-entries-with-search-parameters | JS SDK tutorial} + * @see {@link https://www.contentful.com/developers/docs/references/content-delivery-api/#/reference/search-parameters | REST API reference} + * @example + * ```typescript + * const contentful = require('contentful') + * + * const client = contentful.createClient({ + * space: '', + * accessToken: '' + * }) + * + * const response = await client.getEntriesWithCursor() + * console.log(response.items) + * ``` + */ + getEntriesWithCursor< + EntrySkeleton extends EntrySkeletonType = EntrySkeletonType, + Locales extends LocaleCode = LocaleCode, + >( + query?: EntriesQueriesWithCursor, + ): Promise> + /** * Parse raw json data into a collection of entries. objects.Links will be resolved also * @param data - json data @@ -495,6 +532,30 @@ export interface ContentfulClientApi { query?: AssetsQueries, ): Promise> + /** + * Fetches a cursor paginated collection of assets + * @param pagination - Object with cursor pagination options + * @param query - Object with search parameters + * @see {@link https://www.contentful.com/developers/docs/references/content-delivery-api/#/introduction/cursor-pagination | REST API cursor pagination reference} + * @see {@link https://www.contentful.com/developers/docs/javascript/tutorials/using-js-cda-sdk/#retrieving-entries-with-search-parameters | JS SDK tutorial} + * @see {@link https://www.contentful.com/developers/docs/references/content-delivery-api/#/reference/search-parameters | REST API reference} + * @returns Promise for a cursor paginated collection of Assets + * @typeParam Locales - If provided for a client using `allLocales` modifier, response type defines locale keys for asset field values. + * @example + * const contentful = require('contentful') + * + * const client = contentful.createClient({ + * space: '', + * accessToken: '' + * }) + * + * const response = await client.getAssetsWithCursor() + * console.log(response.items) + */ + getAssetsWithCursor( + query?: AssetsQueriesWithCursor, + ): Promise> + /** * A client that will fetch assets and entries with all locales. Only available if not already enabled. */ diff --git a/lib/types/collection.ts b/lib/types/collection.ts index 27c17b75b..48bb9c939 100644 --- a/lib/types/collection.ts +++ b/lib/types/collection.ts @@ -1,16 +1,50 @@ -import type { AssetSys } from './asset.js' -import type { EntrySys } from './entry.js' +export type CollectionBase = { + limit: number + items: Array + sys?: { + type: 'Array' + } +} + +export type OffsetPagination = { + total: number + skip: number +} + +export type CursorPagination = { + pages: { + next?: string + prev?: string + } +} /** * A wrapper object containing additional information for - * a collection of Contentful resources + * an offset paginated collection of Contentful resources * @category Entity * @see {@link https://www.contentful.com/developers/docs/references/content-delivery-api/#/introduction/collection-resources-and-pagination | Documentation} */ -export interface ContentfulCollection { - total: number - skip: number - limit: number - items: Array - sys?: AssetSys | EntrySys -} +export type OffsetPaginatedCollection = CollectionBase & OffsetPagination + +/** + * A wrapper object containing additional information for + * an offset paginated collection of Contentful resources + * @category Entity + * @see {@link https://www.contentful.com/developers/docs/references/content-delivery-api/#/introduction/collection-resources-and-pagination | Documentation} + */ +export interface ContentfulCollection extends OffsetPaginatedCollection {} + +/** + * A wrapper object containing additional information for + * a curisor paginated collection of Contentful resources + * @category Entity + * @see {@link https://www.contentful.com/developers/docs/references/content-delivery-api/#/introduction/cursor-pagination | Documentation} + */ +export type CursorPaginatedCollection = CollectionBase & CursorPagination + +export type WithCursorPagination = { cursor: true } + +export type CollectionForQuery< + T = unknown, + Query extends Record = Record, +> = Query extends WithCursorPagination ? CursorPaginatedCollection : OffsetPaginatedCollection diff --git a/lib/types/concept-scheme.ts b/lib/types/concept-scheme.ts index 2315e8e3a..474095b12 100644 --- a/lib/types/concept-scheme.ts +++ b/lib/types/concept-scheme.ts @@ -1,3 +1,4 @@ +import type { CursorPaginatedCollection } from './collection' import type { UnresolvedLink } from './link' import type { LocaleCode } from './locale' @@ -27,14 +28,6 @@ export interface ConceptScheme { totalConcepts: number } -export type ConceptSchemeCollection = { - sys: { - type: 'Array' - } - items: ConceptScheme[] - limit: number - pages?: { - prev?: string - next?: string - } -} +export type ConceptSchemeCollection = CursorPaginatedCollection< + ConceptScheme +> diff --git a/lib/types/concept.ts b/lib/types/concept.ts index 556e08fc1..91405174c 100644 --- a/lib/types/concept.ts +++ b/lib/types/concept.ts @@ -1,3 +1,4 @@ +import type { CursorPaginatedCollection } from './collection' import type { UnresolvedLink } from './link' import type { LocaleCode } from './locale' @@ -64,14 +65,6 @@ export interface Concept { conceptSchemes?: UnresolvedLink<'TaxonomyConceptScheme'>[] } -export type ConceptCollection = { - sys: { - type: 'Array' - } - items: Concept[] - limit: number - pages?: { - prev?: string - next?: string - } -} +export type ConceptCollection = CursorPaginatedCollection< + Concept +> diff --git a/lib/types/entry.ts b/lib/types/entry.ts index 0d1257ef0..d32936d15 100644 --- a/lib/types/entry.ts +++ b/lib/types/entry.ts @@ -1,6 +1,6 @@ import type { Document as RichTextDocument } from '@contentful/rich-text-types' import type { Asset } from './asset.js' -import type { ContentfulCollection } from './collection.js' +import type { ContentfulCollection, CursorPaginatedCollection } from './collection.js' import type { ContentTypeLink, UnresolvedLink } from './link.js' import type { LocaleCode } from './locale.js' import type { Metadata } from './metadata.js' @@ -343,3 +343,23 @@ export type EntryCollection< Asset?: Asset[] } } + +/** + * A cursor paginated collection of entries + * @category Entry + * @typeParam EntrySkeleton - Shape of entry fields used to calculate dynamic keys + * @typeParam Modifiers - The chain modifiers used to configure the client. They’re set automatically when using the client chain modifiers. + * @typeParam Locales - If provided for a client using `allLocales` modifier, response type defines locale keys for entry field values. + * @see {@link https://www.contentful.com/developers/docs/references/content-delivery-api/#/reference/entries | Documentation} + */ +export type EntryCursorPaginatedCollection< + EntrySkeleton extends EntrySkeletonType, + Modifiers extends ChainModifiers = ChainModifiers, + Locales extends LocaleCode = LocaleCode, +> = CursorPaginatedCollection> & { + errors?: Array + includes?: { + Entry?: Entry[] + Asset?: Asset[] + } +} diff --git a/lib/types/query/query.ts b/lib/types/query/query.ts index 4f31ea710..9edd78174 100644 --- a/lib/types/query/query.ts +++ b/lib/types/query/query.ts @@ -32,6 +32,17 @@ import type { FieldsType, } from './util.js' +export type CursorPaginationOptions = ( + | { + pagePrev?: string + pageNext?: never + } + | { + pageNext?: string + pagePrev?: never + } +) & { limit?: number; skip?: never } + export type FixedPagedOptions = { skip?: number limit?: number @@ -122,6 +133,17 @@ export type EntriesQueries< FixedLinkOptions) & ('WITH_ALL_LOCALES' extends Modifiers ? {} : LocaleOption)) +/** + * Search parameters for entry cursor paginated collection + * @typeParam EntrySkeleton - Shape of an entry used to calculate dynamic keys + * @typeParam Modifiers - The chain modifiers used to configure the client. They’re set automatically when using the client chain modifiers. + * @category Query + */ +export type EntriesQueriesWithCursor< + EntrySkeleton extends EntrySkeletonType, + Modifiers extends ChainModifiers, +> = EntriesQueries & CursorPaginationOptions + /** * Search parameters for a single entry methods * @typeParam Modifiers - The chain modifiers used to configure the client. They’re set automatically when using the client chain modifiers. @@ -190,6 +212,17 @@ export type AssetsQueries< ? {} : LocaleOption) +/** + * Search parameters for asset cursor paginated collection + * @typeParam EntrySkeleton Shape of an asset used to calculate dynamic keys + * @typeParam Modifiers The chain modifiers used to configure the client. They’re set automatically when using the client chain modifiers. + * @category Query + */ +export type AssetsQueriesWithCursor< + Fields extends FieldsType, + Modifiers extends ChainModifiers, +> = AssetsQueries & CursorPaginationOptions + /** * Search parameters for a single asset methods * @typeParam Modifiers The chain modifiers used to configure the client. They’re set automatically when using the client chain modifiers. @@ -218,15 +251,15 @@ export type TagQueries = TagNameFilters & TagOrderFilter & FixedPagedOptions -type CursorPaginationOptions = { +type ConcenptsCursorPaginationOptions = { limit?: number prevPage?: string nextPage?: string } -export type ConceptsQueries = CursorPaginationOptions & +export type ConceptsQueries = ConcenptsCursorPaginationOptions & TaxonomyOrderFilter & { conceptScheme?: string } -export type ConceptSchemesQueries = CursorPaginationOptions & TaxonomyOrderFilter -export type ConceptAncestorsDescendantsQueries = CursorPaginationOptions & +export type ConceptSchemesQueries = ConcenptsCursorPaginationOptions & TaxonomyOrderFilter +export type ConceptAncestorsDescendantsQueries = ConcenptsCursorPaginationOptions & TaxonomyOrderFilter & { depth?: number } diff --git a/lib/utils/normalize-cursor-pagination-parameters.ts b/lib/utils/normalize-cursor-pagination-parameters.ts new file mode 100644 index 000000000..fe7530538 --- /dev/null +++ b/lib/utils/normalize-cursor-pagination-parameters.ts @@ -0,0 +1,19 @@ +type NormalizedCursorPaginationParams> = Omit< + Query, + 'cursor' | 'skip' +> & { cursor: true } + +export function normalizeCursorPaginationParameters>( + query: Query, +): NormalizedCursorPaginationParams { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { cursor, pagePrev, pageNext, skip, ...rest } = query + + return { + ...rest, + cursor: true, + // omit pagePrev and pageNext if the value is falsy + ...(pagePrev ? { pagePrev } : null), + ...(pageNext ? { pageNext } : null), + } as NormalizedCursorPaginationParams +} diff --git a/lib/utils/normalize-cursor-pagination-response.ts b/lib/utils/normalize-cursor-pagination-response.ts new file mode 100644 index 000000000..29ac2412e --- /dev/null +++ b/lib/utils/normalize-cursor-pagination-response.ts @@ -0,0 +1,35 @@ +import type { CursorPagination } from '../types' + +function extractQueryParam(key: string, url?: string): string | undefined | null { + const queryString = url?.split('?')[1] + + if (!queryString) { + return + } + + return new URLSearchParams(queryString).get(key) +} + +const Pages = { + prev: 'pagePrev', + next: 'pageNext', +} as const + +export function normalizeCursorPaginationResponse( + response: Response, +): Response { + const pages: CursorPagination['pages'] = {} + + for (const [responseKey, queryKey] of Object.entries(Pages)) { + const cursorToken = extractQueryParam(queryKey, response.pages[responseKey]) + + if (cursorToken) { + pages[responseKey] = cursorToken + } + } + + return { + ...response, + pages, + } +} diff --git a/test/integration/getAssetsWithCursor.test.ts b/test/integration/getAssetsWithCursor.test.ts new file mode 100644 index 000000000..8b5c78925 --- /dev/null +++ b/test/integration/getAssetsWithCursor.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, test } from 'vitest' +import * as contentful from '../../lib/contentful' +import { params, previewParamsWithCSM } from './utils' + +if (process.env.API_INTEGRATION_TESTS) { + params.host = '127.0.0.1:5000' + params.insecure = true +} + +const deliveryClient = contentful.createClient(params) +const previewClient = contentful.createClient(previewParamsWithCSM) +const clients = [ + { type: 'default', client: deliveryClient }, + { type: 'preview', client: previewClient }, +] + +describe('getAssetsWithCursor', () => { + clients.forEach(({ type, client }) => { + describe(`${type} client`, () => { + test('should return cursor paginated asset collection when no query provided', async () => { + const response = await client.getAssetsWithCursor() + + expect(response.items).not.toHaveLength(0) + expect(response.pages).toBeDefined() + expect((response as { total?: number }).total).toBeUndefined() + + response.items.forEach((item) => { + expect(item.sys.type).toEqual('Asset') + expect(item.fields).toBeDefined() + expect(typeof item.fields.title).toBe('string') + }) + }) + + test('should return [limit] number of items', async () => { + const response = await client.getAssetsWithCursor({ limit: 3 }) + + expect(response.items).toHaveLength(3) + expect(response.pages).toBeDefined() + expect((response as { total?: number }).total).toBeUndefined() + + response.items.forEach((item) => { + expect(item.sys.type).toEqual('Asset') + expect(item.fields).toBeDefined() + expect(typeof item.fields.title).toBe('string') + }) + }) + + test('should support forward pagination', async () => { + const firstPage = await client.getAssetsWithCursor({ limit: 2 }) + const secondPage = await client.getAssetsWithCursor({ + limit: 2, + pageNext: firstPage.pages.next, + }) + + expect(secondPage.items).toHaveLength(2) + expect(firstPage.items[0].sys.id).not.equal(secondPage.items[0].sys.id) + }) + + test('should support backward pagination', async () => { + const firstPage = await client.getAssetsWithCursor({ limit: 2, order: ['sys.createdAt'] }) + const secondPage = await client.getAssetsWithCursor({ + limit: 2, + pageNext: firstPage.pages.next, + order: ['sys.createdAt'], + }) + const result = await client.getAssetsWithCursor({ + limit: 2, + pagePrev: secondPage.pages.prev, + order: ['sys.createdAt'], + }) + + expect(result.items).toHaveLength(2) + + firstPage.items.forEach((item, index) => { + expect(item.sys.id).equal(result.items[index].sys.id) + }) + }) + }) + }) +}) diff --git a/test/integration/getEntriesWithCursor.test.ts b/test/integration/getEntriesWithCursor.test.ts new file mode 100644 index 000000000..31e1cbcd9 --- /dev/null +++ b/test/integration/getEntriesWithCursor.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from 'vitest' +import * as contentful from '../../lib/contentful' +import { params, previewParamsWithCSM } from './utils' +import { EntryFieldTypes, EntrySkeletonType } from '../../lib' + +if (process.env.API_INTEGRATION_TESTS) { + params.host = '127.0.0.1:5000' + params.insecure = true +} + +const deliveryClient = contentful.createClient(params) +const previewClient = contentful.createClient(previewParamsWithCSM) +const clients = [ + { type: 'default', client: deliveryClient }, + { type: 'preview', client: previewClient }, +] + +describe('getEntriesWithCursor', () => { + const entryWithResolvableLink = 'nyancat' + + clients.forEach(({ type, client }) => { + describe(`${type} client`, () => { + test('should return cursor paginated entry collection when no query provided', async () => { + const response = await client.getEntriesWithCursor< + EntrySkeletonType<{ + bestFriend: EntryFieldTypes.EntryLink + color: EntryFieldTypes.Symbol + }> + >({ 'sys.id': entryWithResolvableLink, include: 2 }) + + expect(response.items).not.toHaveLength(0) + expect(response.pages).toBeDefined() + expect((response as { total?: number }).total).toBeUndefined() + + response.items.forEach((item) => { + expect(item.sys.type).toEqual('Entry') + expect(item.fields).toBeDefined() + }) + }) + + test('should return [limit] number of items', async () => { + const response = await client.getEntriesWithCursor({ limit: 3 }) + + expect(response.items).toHaveLength(3) + expect(response.pages).toBeDefined() + expect((response as { total?: number }).total).toBeUndefined() + + response.items.forEach((item) => { + expect(item.sys.type).toEqual('Entry') + expect(item.fields).toBeDefined() + }) + }) + + test('should support forward pagination', async () => { + const firstPage = await client.getEntriesWithCursor({ limit: 2 }) + const secondPage = await client.getEntriesWithCursor({ + limit: 2, + pageNext: firstPage.pages.next, + }) + + expect(secondPage.items).toHaveLength(2) + expect(firstPage.items[0].sys.id).not.equal(secondPage.items[0].sys.id) + }) + + test('should support backward pagination', async () => { + const firstPage = await client.getEntriesWithCursor({ limit: 2, order: ['sys.createdAt'] }) + const secondPage = await client.getEntriesWithCursor({ + limit: 2, + pageNext: firstPage.pages.next, + order: ['sys.createdAt'], + }) + const result = await client.getEntriesWithCursor({ + limit: 2, + pagePrev: secondPage.pages.prev, + order: ['sys.createdAt'], + }) + + expect(result.items).toHaveLength(2) + + firstPage.items.forEach((item, index) => { + expect(item.sys.id).equal(result.items[index].sys.id) + }) + }) + }) + }) +}) diff --git a/test/types/client/getAssets.test-d.ts b/test/types/client/getAssets.test-d.ts index 3a0e4af7b..1df4857be 100644 --- a/test/types/client/getAssets.test-d.ts +++ b/test/types/client/getAssets.test-d.ts @@ -1,5 +1,5 @@ import { expectType } from 'tsd' -import { Asset, AssetCollection, createClient } from '../../../lib' +import { Asset, AssetCollection, AssetCursorPaginatedCollection, createClient } from '../../../lib' const client = createClient({ accessToken: 'accessToken', @@ -19,3 +19,18 @@ expectType>(await client.withAllLocales.getA expectType>( await client.withAllLocales.getAssets(), ) + +expectType>(await client.getAssetsWithCursor()) +expectType>( + await client.getAssetsWithCursor({ limit: 20 }), +) +expectType>( + await client.getAssetsWithCursor({ pagePrev: 'token' }), +) + +expectType>( + await client.withAllLocales.getAssetsWithCursor(), +) +expectType>( + await client.withAllLocales.getAssetsWithCursor(), +) diff --git a/test/types/client/getEntries.test-d.ts b/test/types/client/getEntries.test-d.ts index 40fec8804..d4eae15ee 100644 --- a/test/types/client/getEntries.test-d.ts +++ b/test/types/client/getEntries.test-d.ts @@ -1,5 +1,11 @@ import { expectType, expectError } from 'tsd' -import { createClient, EntryCollection, Entry, EntrySkeletonType } from '../../../lib' +import { + createClient, + EntryCollection, + Entry, + EntrySkeletonType, + EntryCursorPaginatedCollection, +} from '../../../lib' const client = createClient({ accessToken: 'accessToken', @@ -53,6 +59,45 @@ expectType>( }), ) +expectType>( + await client.getEntriesWithCursor(), +) +expectType>( + await client.getEntriesWithCursor({ limit: 40 }), +) +expectType>( + await client.getEntriesWithCursor({ pageNext: 'next_page_token' }), +) +expectType< + EntryCursorPaginatedCollection +>(await client.withAllLocales.withoutLinkResolution.getEntriesWithCursor()) +expectType< + EntryCursorPaginatedCollection +>(await client.withAllLocales.withoutLinkResolution.getEntriesWithCursor()) +expectType< + EntryCursorPaginatedCollection< + TestEntrySkeleton, + 'WITH_ALL_LOCALES' | 'WITHOUT_LINK_RESOLUTION', + Locale + > +>( + await client.withAllLocales.withoutLinkResolution.getEntriesWithCursor< + TestEntrySkeleton, + Locale + >(), +) +expectType>( + (await client.withAllLocales.withoutLinkResolution.getEntriesWithCursor()).includes!.Entry![0], +) +expectType>( + ( + await client.withAllLocales.withoutLinkResolution.getEntriesWithCursor< + TestEntrySkeleton, + Locale + >() + ).includes!.Entry![0], +) + /** * Without unresolvable Links */ @@ -75,6 +120,18 @@ expectType>( (await client.withoutUnresolvableLinks.getEntries()).includes!.Entry![0], ) +expectType>( + await client.withoutUnresolvableLinks.getEntriesWithCursor(), +) + +expectType>( + await client.withoutUnresolvableLinks.getEntriesWithCursor(), +) + +expectType>( + (await client.withoutUnresolvableLinks.getEntriesWithCursor()).includes!.Entry![0], +) + /** * Without link resolution */ @@ -98,6 +155,18 @@ expectType>( (await client.withoutLinkResolution.getEntries()).includes!.Entry![0], ) +expectType>( + await client.withoutLinkResolution.getEntriesWithCursor(), +) + +expectType>( + await client.withoutLinkResolution.getEntriesWithCursor(), +) + +expectType>( + (await client.withoutLinkResolution.getEntriesWithCursor()).includes!.Entry![0], +) + /** * With all Locales */ @@ -133,6 +202,27 @@ expectType>( (await client.withAllLocales.getEntries()).includes!.Entry![0], ) +expectType>( + await client.withAllLocales.getEntriesWithCursor(), +) + +expectType>( + await client.withAllLocales.getEntriesWithCursor(), +) + +expectType>( + await client.withAllLocales.getEntriesWithCursor(), +) + +expectType>( + (await client.withAllLocales.getEntriesWithCursor()).includes! + .Entry![0], +) + +expectType>( + (await client.withAllLocales.getEntriesWithCursor()).includes!.Entry![0], +) + /** * With all Locales and without unresolvable Links */ @@ -172,6 +262,47 @@ expectType +>(await client.withAllLocales.withoutUnresolvableLinks.getEntriesWithCursor()) + +expectType< + EntryCursorPaginatedCollection< + TestEntrySkeleton, + 'WITH_ALL_LOCALES' | 'WITHOUT_UNRESOLVABLE_LINKS' + > +>(await client.withAllLocales.withoutUnresolvableLinks.getEntriesWithCursor()) + +expectType< + EntryCursorPaginatedCollection< + TestEntrySkeleton, + 'WITH_ALL_LOCALES' | 'WITHOUT_UNRESOLVABLE_LINKS', + Locale + > +>( + await client.withAllLocales.withoutUnresolvableLinks.getEntriesWithCursor< + TestEntrySkeleton, + Locale + >(), +) + +expectType>( + (await client.withAllLocales.withoutUnresolvableLinks.getEntriesWithCursor()) + .includes!.Entry![0], +) + +expectType>( + ( + await client.withAllLocales.withoutUnresolvableLinks.getEntriesWithCursor< + TestEntrySkeleton, + Locale + >() + ).includes!.Entry![0], +) + /** * With all Locales and without link resolution */ @@ -207,3 +338,37 @@ expectType()) .includes!.Entry![0], ) + +expectType< + EntryCursorPaginatedCollection +>(await client.withAllLocales.withoutLinkResolution.getEntriesWithCursor()) + +expectType< + EntryCursorPaginatedCollection +>(await client.withAllLocales.withoutLinkResolution.getEntriesWithCursor()) + +expectType< + EntryCursorPaginatedCollection< + TestEntrySkeleton, + 'WITH_ALL_LOCALES' | 'WITHOUT_LINK_RESOLUTION', + Locale + > +>( + await client.withAllLocales.withoutLinkResolution.getEntriesWithCursor< + TestEntrySkeleton, + Locale + >(), +) + +expectType>( + (await client.withAllLocales.withoutLinkResolution.getEntriesWithCursor()).includes!.Entry![0], +) + +expectType>( + ( + await client.withAllLocales.withoutLinkResolution.getEntriesWithCursor< + TestEntrySkeleton, + Locale + >() + ).includes!.Entry![0], +) diff --git a/test/types/queries/asset-queries.test-d.ts b/test/types/queries/asset-queries.test-d.ts index d6e6f752f..034c2904c 100644 --- a/test/types/queries/asset-queries.test-d.ts +++ b/test/types/queries/asset-queries.test-d.ts @@ -1,5 +1,5 @@ import { expectAssignable, expectNotAssignable } from 'tsd' -import { AssetFields, AssetsQueries } from '../../../lib' +import { AssetFields, AssetsQueries, AssetsQueriesWithCursor } from '../../../lib' import * as mocks from '../mocks' type DefaultAssetQueries = AssetsQueries @@ -201,3 +201,19 @@ expectNotAssignable({ select: ['fields.unknownField'] }) expectAssignable>({ locale: mocks.stringValue }) expectNotAssignable>({ locale: mocks.anyValue }) + +// cursor pagination options + +expectAssignable>({}) +expectAssignable>({ pageNext: 'page_next' }) +expectAssignable>({ + pagePrev: 'page_prev', + limit: 40, +}) + +expectNotAssignable>({ skip: 20 }) +expectNotAssignable>({ pagePrev: 20 }) +expectNotAssignable>({ + pagePrev: 'page_prev', + pageNext: 'page_next', +}) diff --git a/test/types/queries/cursor-pagination-queries.test-d.ts b/test/types/queries/cursor-pagination-queries.test-d.ts new file mode 100644 index 000000000..6d9e64055 --- /dev/null +++ b/test/types/queries/cursor-pagination-queries.test-d.ts @@ -0,0 +1,26 @@ +import { expectAssignable, expectNotAssignable } from 'tsd' +import type { CursorPaginationOptions } from '../../../lib' + +expectAssignable({}) +expectAssignable({ pagePrev: 'prev_token' }) +expectAssignable({ pageNext: 'token_next' }) +expectAssignable({ + pageNext: 'token_next', + pagePrev: undefined, +}) +expectAssignable({ pagePrev: 'token_prev', pageNext: undefined }) +expectAssignable({ pagePrev: undefined, pageNext: undefined }) +expectAssignable({ pagePrev: 'page_prev', limit: 40 }) +expectAssignable({ pageNext: 'page_next', limit: 40 }) +expectAssignable({ limit: 20 }) + +expectNotAssignable({ cursor: false }) +expectNotAssignable({ cursor: undefined }) +expectNotAssignable({ cursor: null }) +expectNotAssignable({ pageNext: 'page_next', pagePrev: 'page_prev' }) +expectNotAssignable({ pageNext: 40 }) +expectNotAssignable({ pagePrev: 40 }) +expectNotAssignable({ skip: 100 }) +expectNotAssignable({ pagePrev: 'page_prev', skip: 20 }) +expectNotAssignable({ pageNext: 'page_next', skip: 20 }) +expectNotAssignable({ limit: 10, skip: 20 }) diff --git a/test/types/queries/entry-queries.test-d.ts b/test/types/queries/entry-queries.test-d.ts index cc3173ab8..82faf5e66 100644 --- a/test/types/queries/entry-queries.test-d.ts +++ b/test/types/queries/entry-queries.test-d.ts @@ -1,5 +1,10 @@ import { expectAssignable, expectNotAssignable } from 'tsd' -import { EntriesQueries, EntrySkeletonType, EntryFieldTypes } from '../../../lib' +import { + EntriesQueries, + EntrySkeletonType, + EntryFieldTypes, + EntriesQueriesWithCursor, +} from '../../../lib' import * as mocks from '../mocks' // all operator @@ -605,3 +610,19 @@ expectNotAssignable< 'WITH_ALL_LOCALES' > >({ locale: mocks.anyValue }) + +// cursor pagination options + +expectAssignable>({}) +expectAssignable>({ pageNext: 'page_next' }) +expectAssignable>({ + pagePrev: 'page_prev', + limit: 40, +}) + +expectNotAssignable>({ skip: 20 }) +expectNotAssignable>({ pagePrev: 20 }) +expectNotAssignable>({ + pagePrev: 'page_prev', + pageNext: 'page_next', +}) diff --git a/test/unit/utils/normalize-cursor-pagination-parameters.test.ts b/test/unit/utils/normalize-cursor-pagination-parameters.test.ts new file mode 100644 index 000000000..9512fb825 --- /dev/null +++ b/test/unit/utils/normalize-cursor-pagination-parameters.test.ts @@ -0,0 +1,53 @@ +import { describe, test, expect } from 'vitest' +import { normalizeCursorPaginationParameters } from '../../../lib/utils/normalize-cursor-pagination-parameters' + +describe('normalizeCursorPaginationParameters', () => { + test('should add cursor=true param', () => { + expect(normalizeCursorPaginationParameters({}).cursor).toBe(true) + expect(normalizeCursorPaginationParameters({ cursor: false }).cursor).toBe(true) + expect(normalizeCursorPaginationParameters({ cursor: false, pageNext: '' }).cursor).toBe(true) + }) + + test('should omit "skip" param from the query', () => { + expect(normalizeCursorPaginationParameters({ skip: 20 })).not.property('skip') + }) + + test('should omit pagePrev and pageNext when falsy', () => { + ;[ + normalizeCursorPaginationParameters({ pagePrev: false, pageNext: null }), + normalizeCursorPaginationParameters({ pagePrev: '' }), + normalizeCursorPaginationParameters({ pagePrev: undefined }), + normalizeCursorPaginationParameters({ pageNext: '' }), + normalizeCursorPaginationParameters({ pageNext: undefined }), + normalizeCursorPaginationParameters({ pagePrev: undefined, pageNext: '' }), + ].forEach((result) => { + expect(result).not.property('pagePrev') + expect(result).not.property('pageNext') + }) + }) + + test('should independently pass pagePrev and pageNext when truthy', () => { + expect(normalizeCursorPaginationParameters({ pagePrev: 'test' }).pagePrev).toBe('test') + expect(normalizeCursorPaginationParameters({ pagePrev: 'test' })).not.property('pageNext') + expect(normalizeCursorPaginationParameters({ pageNext: 'next' }).pageNext).toBe('next') + expect(normalizeCursorPaginationParameters({ pageNext: 'next' })).not.property('pagePrev') + expect(normalizeCursorPaginationParameters({ pageNext: 'next', pagePrev: 'prev' })).contain({ + pageNext: 'next', + pagePrev: 'prev', + }) + }) + + test('should pass all the other fields', () => { + const params = { + query: 'items', + select: 'sys.id', + timestamp: '2025-11-14T16:10:22.977Z', + pageNext: 'next', + } + + expect(normalizeCursorPaginationParameters(params)).deep.equal({ + ...params, + cursor: true, + }) + }) +}) diff --git a/test/unit/utils/normalize-cursor-pagination-response.test.ts b/test/unit/utils/normalize-cursor-pagination-response.test.ts new file mode 100644 index 000000000..4bbce7c5d --- /dev/null +++ b/test/unit/utils/normalize-cursor-pagination-response.test.ts @@ -0,0 +1,88 @@ +import { describe, test, expect } from 'vitest' +import { normalizeCursorPaginationResponse } from '../../../lib/utils/normalize-cursor-pagination-response' + +const prevToken = + 'wqXDgmPDrT3CqsKBw4QewrY8YcOoeFBn.W3siY3Vyc29yIjoidHJ1ZSIsImxpbWl0IjoiMiIsInNlbGVjdCI6InN5cy5pZCJ9LFsiMjAyNS0xMC0wOVQwOToxNjozMC40MzhaIiwiMmNlSDhURlkxS1IzR0VLa05heTBtWSJdXQ' +const prevRaw = '/spaces/87cj9boavvn1/entries?pagePrev=' + prevToken + +const nextToken = + 'VAbCliFbVsOvwpw9UWpewqxWw7jDjw.W3siY3Vyc29yIjoidHJ1ZSIsImxpbWl0IjoiMiIsInNlbGVjdCI6InN5cy5pZCJ9LFsiMjAyNS0wOS0xNVQxNToyMjoyNS42MDZaIiwiMTBjQXR6RWxadmRnbkJFc3hHOHlUUCJdXQ' +const nextRaw = '/spaces/87cj9boavvn1/entries?pageNext=' + nextToken + +describe('normalizeCursorPaginationResponse', () => { + test('should not update response when "pages" is empty', () => { + expect(normalizeCursorPaginationResponse({ pages: {} })).deep.equal({ + pages: {}, + }) + }) + + test('should normalize prev page token when presented', () => { + expect( + normalizeCursorPaginationResponse({ + pages: { + prev: prevRaw, + }, + }), + ).deep.equal({ + pages: { + prev: prevToken, + }, + }) + }) + + test('should normalize next page token when presented', () => { + expect( + normalizeCursorPaginationResponse({ + pages: { + next: nextRaw, + }, + }), + ).deep.equal({ + pages: { + next: nextToken, + }, + }) + }) + + test('should normalize prev and next pages tokens when both presented', () => { + expect( + normalizeCursorPaginationResponse({ + pages: { + prev: prevRaw, + next: nextRaw, + }, + }), + ).deep.equal({ + pages: { + prev: prevToken, + next: nextToken, + }, + }) + }) + + test('should pass all the other fields', () => { + expect( + normalizeCursorPaginationResponse({ + sys: { + type: 'Array', + }, + limit: 2, + items: ['item', 'item'], + pages: { + prev: prevRaw, + next: nextRaw, + }, + }), + ).deep.equal({ + sys: { + type: 'Array', + }, + limit: 2, + items: ['item', 'item'], + pages: { + prev: prevToken, + next: nextToken, + }, + }) + }) +}) From 19610d17ca7f3d01f9c8aaa1afad924b858ce8d7 Mon Sep 17 00:00:00 2001 From: Pavel Mareev Date: Fri, 14 Nov 2025 19:12:26 +0100 Subject: [PATCH 2/2] docs: add cursor pagination docs [CAPI-2342] --- README.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/README.md b/README.md index 501656758..c028bbe60 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,7 @@ JavaScript library for the Contentful [Content Delivery API](https://www.content - [Your first request](#your-first-request) - [Using this library with the Preview API](#using-this-library-with-the-preview-api) - [Authentication](#authentication) + - [Cursor-based Pagination](#cursor-based-pagination) - [Documentation \& References](#documentation--references) - [Configuration](#configuration) - [Request configuration options](#request-configuration-options) @@ -133,6 +134,7 @@ In order to get started with the Contentful JS library you'll need not only to i - [Your first request](#your-first-request) - [Using this library with the Preview API](#using-this-library-with-the-preview-api) - [Authentication](#authentication) +- [Cursor-based pagination](#cursor-based-pagination) - [Documentation & References](#documentation--references) ### Installation @@ -227,6 +229,29 @@ Don't forget to also get your Space ID. For more information, check the [Contentful REST API reference on Authentication](https://www.contentful.com/developers/docs/references/authentication/). +### Cursor-based Pagination + +Cursor-based pagination is supported on collection endpoints for entries and assets: + +```js +const response = await client.getEntriesWithCursor({ limit: 10 }) +console.log(response.items) // Array of items +console.log(response.pages?.next) // Cursor for next page +``` + +Use the value from `response.pages.next` to fetch the next page or `response.pages.prev` to fetch the previous page. + +```js +const nextPageResponse = await client.getEntriesWithCursor({ + limit: 10, + pageNext: response.pages?.next, +}) + +console.log(nextPageResponse.items) // Array of items +console.log(nextPageResponse.pages?.next) // Cursor for next page +console.log(nextPageResponse.pages?.prev) // Cursor for prev page +``` + ## Documentation & References - [Configuration](#configuration)