Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
- [Configuration](#configuration)
- [Reference Documentation](#reference-documentation)
- [Contentful Javascript resources](#contentful-javascript-resources)
- [Cursor Based Pagination](#cursor-based-pagination)
- [REST API reference](#rest-api-reference)
- [Versioning](#versioning)
- [Reach out to us](#reach-out-to-us)
Expand Down Expand Up @@ -226,6 +227,25 @@ The benefits of using the "plain" version of the client, over the legacy version
- The ability to scope CMA client instance to a specific `spaceId`, `environmentId`, and `organizationId` when initializing the client.
- You can pass a concrete values to `defaults` and omit specifying these params in actual CMA methods calls.

## Cursor Based Pagination

Cursor-based pagination is supported on collection endpoints for content types, entries, and assets. To use cursor-based pagination, use the related entity methods `getAssetsWithCursor`, `getContentTypesWithCursor`, and `getEntriesWithCursor`

```js
const response = await environment.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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we also add an example on how to use the pages.next token?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can do


```js
const secondPage = await environment.getEntriesWithCursor({
limit: 2,
pageNext: response.pages?.next,
});
console.log(secondPage.items); // Array of items
```

## Legacy Client Interface

The following code snippet is an example of the legacy client interface, which reads and writes data as a sequence of nested requests:
Expand Down
19 changes: 19 additions & 0 deletions lib/common-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,7 @@ export interface BasicQueryOptions {
}

export interface BasicCursorPaginationOptions extends Omit<BasicQueryOptions, 'skip'> {
skip?: never
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this skip unnecessary given the Omit above?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this type used in all the non cursor based functions as signature? 🤔

Copy link
Author

@ebefarooqui ebefarooqui Nov 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ethan-ozelius-contentful It's there because unless strictly forbidden, the value can still be passed in at runtime. I wanted to make sure to have a type error there just in case.

@marcolink Could you point to where that is the case? I'm not sure I follow right now, apologies

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see. I'm not sure why, but can investigate. I was using the same type to idiomatically match how we were using it elsewhere.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately, changing that now would likely create a breaking change ...

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, we can make a point to investigate what's happening here and if need be spin up another ticket?

pageNext?: string
pagePrev?: string
}
Expand Down Expand Up @@ -487,6 +488,7 @@ type MRInternal<UA extends boolean> = {
): MRReturn<'AppInstallation', 'getForOrganization'>

(opts: MROpts<'Asset', 'getMany', UA>): MRReturn<'Asset', 'getMany'>
(opts: MROpts<'Asset', 'getManyWithCursor', UA>): MRReturn<'Asset', 'getManyWithCursor'>
(opts: MROpts<'Asset', 'getPublished', UA>): MRReturn<'Asset', 'getPublished'>
(opts: MROpts<'Asset', 'get', UA>): MRReturn<'Asset', 'get'>
(opts: MROpts<'Asset', 'update', UA>): MRReturn<'Asset', 'update'>
Expand Down Expand Up @@ -567,6 +569,9 @@ type MRInternal<UA extends boolean> = {

(opts: MROpts<'ContentType', 'get', UA>): MRReturn<'ContentType', 'get'>
(opts: MROpts<'ContentType', 'getMany', UA>): MRReturn<'ContentType', 'getMany'>
(
opts: MROpts<'ContentType', 'getManyWithCursor', UA>,
): MRReturn<'ContentType', 'getManyWithCursor'>
(opts: MROpts<'ContentType', 'update', UA>): MRReturn<'ContentType', 'update'>
(opts: MROpts<'ContentType', 'create', UA>): MRReturn<'ContentType', 'create'>
(opts: MROpts<'ContentType', 'createWithId', UA>): MRReturn<'ContentType', 'createWithId'>
Expand Down Expand Up @@ -616,6 +621,7 @@ type MRInternal<UA extends boolean> = {
): MRReturn<'EnvironmentTemplateInstallation', 'getForEnvironment'>

(opts: MROpts<'Entry', 'getMany', UA>): MRReturn<'Entry', 'getMany'>
(opts: MROpts<'Entry', 'getManyWithCursor', UA>): MRReturn<'Entry', 'getManyWithCursor'>
(opts: MROpts<'Entry', 'getPublished', UA>): MRReturn<'Entry', 'getPublished'>
(opts: MROpts<'Entry', 'get', UA>): MRReturn<'Entry', 'get'>
(opts: MROpts<'Entry', 'patch', UA>): MRReturn<'Entry', 'patch'>
Expand Down Expand Up @@ -1234,6 +1240,11 @@ export type MRActions = {
headers?: RawAxiosRequestHeaders
return: CollectionProp<AssetProps>
}
getManyWithCursor: {
params: GetSpaceEnvironmentParams & CursorBasedParams & { releaseId?: string }
headers?: RawAxiosRequestHeaders
return: CursorPaginatedCollectionProp<AssetProps>
}
get: {
params: GetSpaceEnvironmentParams & { assetId: string; releaseId?: string } & QueryParams
headers?: RawAxiosRequestHeaders
Expand Down Expand Up @@ -1482,6 +1493,10 @@ export type MRActions = {
params: GetSpaceEnvironmentParams & QueryParams
return: CollectionProp<ContentTypeProps>
}
getManyWithCursor: {
params: GetSpaceEnvironmentParams & CursorBasedParams
return: CursorPaginatedCollectionProp<ContentTypeProps>
}
create: {
params: GetSpaceEnvironmentParams
payload: CreateContentTypeProps
Expand Down Expand Up @@ -1650,6 +1665,10 @@ export type MRActions = {
params: GetSpaceEnvironmentParams & QueryParams & { releaseId?: string }
return: CollectionProp<EntryProps<any>>
}
getManyWithCursor: {
params: GetSpaceEnvironmentParams & CursorBasedParams & { releaseId?: string }
return: CursorPaginatedCollectionProp<EntryProps<any>>
}
get: {
params: GetSpaceEnvironmentParams & { entryId: string; releaseId?: string } & QueryParams
return: EntryProps<any>
Expand Down
49 changes: 49 additions & 0 deletions lib/common-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
import { toPlainObject } from 'contentful-sdk-core'
import copy from 'fast-copy'
import type {
BasicCursorPaginationOptions,
Collection,
CollectionProp,
CursorBasedParams,
CursorPaginatedCollection,
CursorPaginatedCollectionProp,
MakeRequest,
Expand Down Expand Up @@ -47,3 +49,50 @@ export function shouldRePoll(statusCode: number) {
export async function waitFor(ms = 1000) {
return new Promise((resolve) => setTimeout(resolve, ms))
}

export function normalizeCursorPaginationParameters(
query: BasicCursorPaginationOptions,
): CursorBasedParams {
const { pagePrev, pageNext, ...rest } = query

return {
...rest,
cursor: true,
// omit pagePrev and pageNext if the value is falsy
...(pagePrev ? { pagePrev } : null),
...(pageNext ? { pageNext } : null),
} as CursorBasedParams
}

function extractQueryParam(key: string, url?: string): string | undefined {
if (!url) return

const queryIndex = url.indexOf('?')
if (queryIndex === -1) return

const queryString = url.slice(queryIndex + 1)
return new URLSearchParams(queryString).get(key) ?? undefined
}

const Pages = {
prev: 'pagePrev',
next: 'pageNext',
} as const

const PAGE_KEYS = ['prev', 'next'] as const

export function normalizeCursorPaginationResponse<T>(
data: CursorPaginatedCollectionProp<T>,
): CursorPaginatedCollectionProp<T> {
const pages: { prev?: string; next?: string } = {}

for (const key of PAGE_KEYS) {
const token = extractQueryParam(Pages[key], data.pages?.[key])
if (token) pages[key] = token
}

return {
...data,
pages,
}
}
151 changes: 137 additions & 14 deletions lib/create-environment-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import type {
CursorBasedParams,
QueryOptions,
} from './common-types'
import {
normalizeCursorPaginationParameters,
normalizeCursorPaginationResponse,
} from './common-utils'
import type { BasicQueryOptions, MakeRequest } from './common-types'
import entities from './entities'
import type { CreateAppInstallationProps } from './entities/app-installation'
Expand All @@ -15,11 +19,11 @@ import type {
CreateAppActionCallProps,
AppActionCallRawResponseProps,
} from './entities/app-action-call'
import type {
AssetFileProp,
AssetProps,
CreateAssetFromFilesOptions,
CreateAssetProps,
import {
type AssetFileProp,
type AssetProps,
type CreateAssetFromFilesOptions,
type CreateAssetProps,
} from './entities/asset'
import type { CreateAssetKeyProps } from './entities/asset-key'
import type {
Expand All @@ -40,12 +44,12 @@ import type {
} from './entities/release'
import { wrapRelease, wrapReleaseCollection } from './entities/release'

import type { ContentTypeProps, CreateContentTypeProps } from './entities/content-type'
import type {
CreateEntryProps,
EntryProps,
EntryReferenceOptionsProps,
EntryReferenceProps,
import { type ContentTypeProps, type CreateContentTypeProps } from './entities/content-type'
import {
type CreateEntryProps,
type EntryProps,
type EntryReferenceOptionsProps,
type EntryReferenceProps,
} from './entities/entry'
import type { EnvironmentProps } from './entities/environment'
import type { CreateExtensionProps } from './entities/extension'
Expand Down Expand Up @@ -75,9 +79,10 @@ export type ContentfulEnvironmentAPI = ReturnType<typeof createEnvironmentApi>
*/
export default function createEnvironmentApi(makeRequest: MakeRequest) {
const { wrapEnvironment } = entities.environment
const { wrapContentType, wrapContentTypeCollection } = entities.contentType
const { wrapEntry, wrapEntryCollection } = entities.entry
const { wrapAsset, wrapAssetCollection } = entities.asset
const { wrapContentType, wrapContentTypeCollection, wrapContentTypeCursorPaginatedCollection } =
entities.contentType
const { wrapEntry, wrapEntryCollection, wrapEntryTypeCursorPaginatedCollection } = entities.entry
const { wrapAsset, wrapAssetCollection, wrapAssetTypeCursorPaginatedCollection } = entities.asset
const { wrapAssetKey } = entities.assetKey
const { wrapLocale, wrapLocaleCollection } = entities.locale
const { wrapSnapshotCollection } = entities.snapshot
Expand Down Expand Up @@ -492,6 +497,44 @@ export default function createEnvironmentApi(makeRequest: MakeRequest) {
},
}).then((data) => wrapContentTypeCollection(makeRequest, data))
},

/**
* Gets a collection of Content Types with cursor based pagination
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These two URLs listed in this @param should probably point to the follow URL right?

https://www.contentful.com/developers/docs/references/content-management-api/#/introduction/cursor-pagination?

* @param query - Object with cursor pagination parameters. Check the <a href="https://www.contentful.com/developers/docs/references/content-management-api/#/introduction/cursor-pagination">REST API reference</a> for more details.
* @return Promise for a collection of Content Types
* @example ```javascript
* const contentful = require('contentful-management')
*
* const client = contentful.createClient({
* accessToken: '<content_management_api_key>'
* })
*
* client.getSpace('<space_id>')
* .then((space) => space.getEnvironment('<environment-id>'))
* .then((environment) => environment.getContentTypesWithCursor())
* .then((response) => console.log(response.items))
* .catch(console.error)
* ```
*/
getContentTypesWithCursor(query: BasicCursorPaginationOptions = {}) {
const raw = this.toPlainObject() as EnvironmentProps
const normalizedQueryParams = normalizeCursorPaginationParameters(query)
return makeRequest({
entityType: 'ContentType',
action: 'getMany',
params: {
spaceId: raw.sys.space.sys.id,
environmentId: raw.sys.id,
query: createRequestConfig({ query: normalizedQueryParams }).params,
},
}).then((data) =>
wrapContentTypeCursorPaginatedCollection(
makeRequest,
normalizeCursorPaginationResponse(data),
),
)
},

/**
* Creates a Content Type
* @param data - Object representation of the Content Type to be created
Expand Down Expand Up @@ -740,6 +783,45 @@ export default function createEnvironmentApi(makeRequest: MakeRequest) {
}).then((data) => wrapEntryCollection(makeRequest, data))
},

/**
* Gets a collection of Entries with cursor based pagination
* Warning: if you are using the select operator, when saving, any field that was not selected will be removed
* from your entry in the backend
* @param query - Object with cursor pagination parameters. Check the <a href="https://www.contentful.com/developers/docs/references/content-management-api/#/introduction/cursor-pagination">REST API reference</a> for more details.
* @return Promise for a collection of Entries
* @example ```javascript
* const contentful = require('contentful-management')
*
* const client = contentful.createClient({
* accessToken: '<content_management_api_key>'
* })
*
* client.getSpace('<space_id>')
* .then((space) => space.getEnvironment('<environment-id>'))
* .then((environment) => environment.getEntriesWithCursor({'content_type': 'foo'})) // you can add more queries as 'key': 'value'
* .then((response) => console.log(response.items))
* .catch(console.error)
* ```
*/
getEntriesWithCursor(query: BasicCursorPaginationOptions = {}) {
const raw = this.toPlainObject() as EnvironmentProps
const normalizedQueryParams = normalizeCursorPaginationParameters(query)
return makeRequest({
entityType: 'Entry',
action: 'getMany',
params: {
spaceId: raw.sys.space.sys.id,
environmentId: raw.sys.id,
query: createRequestConfig({ query: normalizedQueryParams }).params,
},
}).then((data) =>
wrapEntryTypeCursorPaginatedCollection(
makeRequest,
normalizeCursorPaginationResponse(data),
),
)
},

/**
* Gets a collection of published Entries
* @param query - Object with search parameters. Check the <a href="https://www.contentful.com/developers/docs/javascript/tutorials/using-js-cda-sdk/#retrieving-entries-with-search-parameters">JS SDK tutorial</a> and the <a href="https://www.contentful.com/developers/docs/references/content-delivery-api/#/reference/search-parameters">REST API reference</a> for more details.
Expand Down Expand Up @@ -955,6 +1037,46 @@ export default function createEnvironmentApi(makeRequest: MakeRequest) {
},
}).then((data) => wrapAssetCollection(makeRequest, data))
},

/**
* Gets a collection of Assets with cursor based pagination
* Warning: if you are using the select operator, when saving, any field that was not selected will be removed
* from your entry in the backend
* @param query - Object with cursor pagination parameters. Check the <a href="https://www.contentful.com/developers/docs/references/content-management-api/#/introduction/cursor-pagination">REST API reference</a> for more details.
* @return Promise for a collection of Assets
* @example ```javascript
* const contentful = require('contentful-management')
*
* const client = contentful.createClient({
* accessToken: '<content_management_api_key>'
* })
*
* client.getSpace('<space_id>')
* .then((space) => space.getEnvironment('<environment-id>'))
* .then((environment) => environment.getAssetsWithCursor())
* .then((response) => console.log(response.items))
* .catch(console.error)
* ```
*/
getAssetsWithCursor(query: BasicCursorPaginationOptions = {}) {
const raw = this.toPlainObject() as EnvironmentProps
const normalizedQueryParams = normalizeCursorPaginationParameters(query)
return makeRequest({
entityType: 'Asset',
action: 'getMany',
params: {
spaceId: raw.sys.space.sys.id,
environmentId: raw.sys.id,
query: createRequestConfig({ query: normalizedQueryParams }).params,
},
}).then((data) =>
wrapAssetTypeCursorPaginatedCollection(
makeRequest,
normalizeCursorPaginationResponse(data),
),
)
},

/**
* Gets a collection of published Assets
* @param query - Object with search parameters. Check the <a href="https://www.contentful.com/developers/docs/javascript/tutorials/using-js-cda-sdk/#retrieving-entries-with-search-parameters">JS SDK tutorial</a> and the <a href="https://www.contentful.com/developers/docs/references/content-delivery-api/#/reference/search-parameters">REST API reference</a> for more details.
Expand Down Expand Up @@ -985,6 +1107,7 @@ export default function createEnvironmentApi(makeRequest: MakeRequest) {
},
}).then((data) => wrapAssetCollection(makeRequest, data))
},

/**
* Creates a Asset. After creation, call asset.processForLocale or asset.processForAllLocales to start asset processing.
* @param data - Object representation of the Asset to be created. Note that the field object should have an upload property on asset creation, which will be removed and replaced with an url property when processing is finished.
Expand Down
8 changes: 7 additions & 1 deletion lib/entities/asset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ import type {
EntityMetaSysProps,
MetadataProps,
MakeRequest,
CursorPaginatedCollectionProp,
} from '../common-types'
import { wrapCollection } from '../common-utils'
import { wrapCollection, wrapCursorPaginatedCollection } from '../common-utils'
import * as checks from '../plain/checks'

export type AssetProps<S = {}> = {
Expand Down Expand Up @@ -410,3 +411,8 @@ export function wrapAsset(makeRequest: MakeRequest, data: AssetProps): Asset {
* @private
*/
export const wrapAssetCollection = wrapCollection(wrapAsset)

/**
* @private
*/
export const wrapAssetTypeCursorPaginatedCollection = wrapCursorPaginatedCollection(wrapAsset)
Loading