/** * Contentful Delivery API Client. Contains methods which allow access to the * different kinds of entities present in Contentful (Entries, Assets, etc). */ import { encodeCPAResponse } from '@contentful/content-source-maps' import type { AxiosInstance } from 'contentful-sdk-core' import { createRequestConfig, errorHandler } from 'contentful-sdk-core' import type { CreateClientParams } from './contentful.js' import type { GetGlobalOptions } from './create-global-options.js' import pagedSync from './paged-sync.js' import type { Asset, AssetCollection, AssetKey, ContentfulClientApi, ContentType, ContentTypeCollection, LocaleCollection, Space, Tag, TagCollection, EntryCollection, SyncQuery, SyncOptions, EntrySkeletonType, LocaleCode, ConceptCollection, Concept, ConceptScheme, ConceptSchemeCollection, } from './types/index.js' import normalizeSearchParameters from './utils/normalize-search-parameters.js' import normalizeSelect from './utils/normalize-select.js' import resolveCircular from './utils/resolve-circular.js' import getQuerySelectionSet from './utils/query-selection-set.js' import validateTimestamp from './utils/validate-timestamp.js' import type { ChainOptions, ModifiersFromOptions } from './utils/client-helpers.js' import { checkIncludeContentSourceMapsParamIsAllowed, validateLocaleParam, validateRemoveUnresolvedParam, validateResolveLinksParam, } from './utils/validate-params.js' import validateSearchParameters from './utils/validate-search-parameters.js' const ASSET_KEY_MAX_LIFETIME = 48 * 60 * 60 export interface CreateContentfulApiParams { http: AxiosInstance getGlobalOptions: GetGlobalOptions } class NotFoundError extends Error { public readonly sys: { id: string; type: string } public readonly details: { environment: string; id: string; type: string; space: any } constructor(id: string, environment: string, space: string) { super('The resource could not be found.') this.sys = { type: 'Error', id: 'NotFound', } this.details = { type: 'Entry', id, environment, space, } } } export default function createContentfulApi<OptionType extends ChainOptions>( { http, getGlobalOptions }: CreateContentfulApiParams, options?: OptionType, ): ContentfulClientApi<undefined> { const notFoundError = (id = 'unknown') => { return new NotFoundError(id, getGlobalOptions().environment, getGlobalOptions().space) } type Context = 'space' | 'environment' interface GetConfig { context: Context path: string config?: any } interface PostConfig extends GetConfig { data?: any } const getBaseUrl = (context: Context) => { let baseUrl = context === 'space' ? getGlobalOptions().spaceBaseUrl : getGlobalOptions().environmentBaseUrl if (!baseUrl) { throw new Error('Please define baseUrl for ' + context) } if (!baseUrl.endsWith('/')) { baseUrl += '/' } return baseUrl } function maybeEnableSourceMaps(query: Record<string, any> = {}): Record<string, any> { const params = http.httpClientParams as CreateClientParams const includeContentSourceMaps = params?.includeContentSourceMaps ?? params?.alphaFeatures?.includeContentSourceMaps const host = params?.host const areAllowed = checkIncludeContentSourceMapsParamIsAllowed(host, includeContentSourceMaps) if (areAllowed) { query.includeContentSourceMaps = true // Ensure that content source maps and required attributes are selected if (query.select) { const selection = getQuerySelectionSet(query) selection.add('sys') query.select = Array.from(selection).join(',') } } return query } function maybeEncodeCPAResponse(data: any, config: Record<string, any>): any { const includeContentSourceMaps = config?.params?.includeContentSourceMaps as boolean if (includeContentSourceMaps) { return encodeCPAResponse(data) } return data } async function get<T>({ context, path, config }: GetConfig): Promise<T> { const baseUrl = getBaseUrl(context) try { const response = await http.get(baseUrl + path, config) return maybeEncodeCPAResponse(response.data, config) } catch (error) { errorHandler(error) } } async function post<T>({ context, path, data, config }: PostConfig): Promise<T> { const baseUrl = getBaseUrl(context) try { const response = await http.post(baseUrl + path, data, config) return response.data } catch (error) { errorHandler(error) } } async function getSpace(): Promise<Space> { return get<Space>({ context: 'space', path: '' }) } async function getContentType(id: string): Promise<ContentType> { return get<ContentType>({ context: 'environment', path: `content_types/${id}`, }) } async function getContentTypes(query: { query?: string } = {}): Promise<ContentTypeCollection> { return get<ContentTypeCollection>({ context: 'environment', path: 'content_types', config: createRequestConfig({ query }), }) } async function getEntry(id, query = {}) { return makeGetEntry(id, query, options) } async function getEntries(query = {}) { return makeGetEntries(query, options) } async function makeGetEntry<EntrySkeleton extends EntrySkeletonType>( id: string, query, options: ChainOptions = { withAllLocales: false, withoutLinkResolution: false, withoutUnresolvableLinks: false, }, ) { const { withAllLocales } = options validateLocaleParam(query, withAllLocales as boolean) validateResolveLinksParam(query) validateRemoveUnresolvedParam(query) validateSearchParameters(query) return internalGetEntry<EntrySkeleton, any, Extract<ChainOptions, typeof options>>( id, withAllLocales ? { ...query, locale: '*' } : query, options, ) } async function internalGetEntry< EntrySkeleton extends EntrySkeletonType, Locales extends LocaleCode, Options extends ChainOptions, >(id: string, query, options: Options) { if (!id) { throw notFoundError(id) } try { const response = await internalGetEntries<EntrySkeletonType<EntrySkeleton>, Locales, Options>( { 'sys.id': id, ...maybeEnableSourceMaps(query) }, options, ) if (response.items.length > 0) { return response.items[0] } else { throw notFoundError(id) } } catch (error) { errorHandler(error) } } async function makeGetEntries<EntrySkeleton extends EntrySkeletonType>( query, options: ChainOptions = { withAllLocales: false, withoutLinkResolution: false, withoutUnresolvableLinks: false, }, ) { const { withAllLocales } = options validateLocaleParam(query, withAllLocales) validateResolveLinksParam(query) validateRemoveUnresolvedParam(query) validateSearchParameters(query) return internalGetEntries<EntrySkeleton, any, Extract<ChainOptions, typeof options>>( withAllLocales ? { ...query, locale: '*', } : query, options, ) } async function internalGetEntries< EntrySkeleton extends EntrySkeletonType, Locales extends LocaleCode, Options extends ChainOptions, >( query: Record<string, any>, options: Options, ): Promise<EntryCollection<EntrySkeleton, ModifiersFromOptions<Options>, Locales>> { const { withoutLinkResolution, withoutUnresolvableLinks } = options try { const entries = await get({ context: 'environment', path: 'entries', config: createRequestConfig({ query: maybeEnableSourceMaps(normalizeSearchParameters(normalizeSelect(query))), }), }) return resolveCircular(entries, { resolveLinks: !withoutLinkResolution, removeUnresolved: withoutUnresolvableLinks ?? false, }) } catch (error) { errorHandler(error) } } async function getAsset(id: string, query: Record<string, any> = {}): Promise<Asset> { return makeGetAsset(id, query, options) } async function getAssets(query: Record<string, any> = {}): Promise<AssetCollection> { return makeGetAssets(query, options) } async function makeGetAssets( query: Record<string, any>, options: ChainOptions = { withAllLocales: false, withoutLinkResolution: false, withoutUnresolvableLinks: false, }, ) { const { withAllLocales } = options validateLocaleParam(query, withAllLocales) validateSearchParameters(query) const localeSpecificQuery = withAllLocales ? { ...query, locale: '*' } : query return internalGetAssets<any, Extract<ChainOptions, typeof options>>(localeSpecificQuery) } async function internalGetAsset<Locales extends LocaleCode, Options extends ChainOptions>( id: string, query: Record<string, any>, ): Promise<Asset<ModifiersFromOptions<Options>, Locales>> { try { return get({ context: 'environment', path: `assets/${id}`, config: createRequestConfig({ query: maybeEnableSourceMaps(normalizeSelect(query)) }), }) } catch (error) { errorHandler(error) } } async function makeGetAsset( id: string, query: Record<string, any>, options: ChainOptions = { withAllLocales: false, withoutLinkResolution: false, withoutUnresolvableLinks: false, }, ) { const { withAllLocales } = options validateLocaleParam(query, withAllLocales) validateSearchParameters(query) const localeSpecificQuery = withAllLocales ? { ...query, locale: '*' } : query return internalGetAsset<any, Extract<ChainOptions, typeof options>>(id, localeSpecificQuery) } async function internalGetAssets<Locales extends LocaleCode, Options extends ChainOptions>( query: Record<string, any>, ): Promise<AssetCollection<ModifiersFromOptions<Options>, Locales>> { try { return get({ context: 'environment', path: 'assets', config: createRequestConfig({ query: maybeEnableSourceMaps(normalizeSearchParameters(normalizeSelect(query))), }), }) } catch (error) { errorHandler(error) } } async function getTag(id: string): Promise<Tag> { return get<Tag>({ context: 'environment', path: `tags/${id}`, }) } async function getTags(query = {}): Promise<TagCollection> { validateSearchParameters(query) return get<TagCollection>({ context: 'environment', path: 'tags', config: createRequestConfig({ query: normalizeSearchParameters(normalizeSelect(query)) }), }) } async function createAssetKey(expiresAt: number): Promise<AssetKey> { try { const now = Math.floor(Date.now() / 1000) const currentMaxLifetime = now + ASSET_KEY_MAX_LIFETIME validateTimestamp('expiresAt', expiresAt, { maximum: currentMaxLifetime, now }) } catch (error) { errorHandler(error) } return post<AssetKey>({ context: 'environment', path: 'asset_keys', data: { expiresAt }, }) } async function getLocales(query = {}): Promise<LocaleCollection> { validateSearchParameters(query) return get<LocaleCollection>({ context: 'environment', path: 'locales', config: createRequestConfig({ query: normalizeSelect(query) }), }) } async function sync<EntrySkeleton extends EntrySkeletonType = EntrySkeletonType>( query: SyncQuery, syncOptions: SyncOptions = { paginate: true }, ) { return makePagedSync<EntrySkeleton>(query, syncOptions, options) } async function makePagedSync<EntrySkeleton extends EntrySkeletonType = EntrySkeletonType>( query: SyncQuery, syncOptions: SyncOptions, options: ChainOptions = { withAllLocales: false, withoutLinkResolution: false, withoutUnresolvableLinks: false, }, ) { validateResolveLinksParam(query) validateRemoveUnresolvedParam(query) const combinedOptions = { ...syncOptions, ...options, } switchToEnvironment(http) return pagedSync<EntrySkeleton, any, Extract<ChainOptions, typeof options>>( http, query, combinedOptions, ) } function parseEntries<EntrySkeleton extends EntrySkeletonType = EntrySkeletonType>(data) { return makeParseEntries<EntrySkeleton>(data, options) } function makeParseEntries<EntrySkeleton extends EntrySkeletonType>( data, options: ChainOptions = { withAllLocales: false, withoutLinkResolution: false, withoutUnresolvableLinks: false, }, ) { return internalParseEntries<EntrySkeleton, any, Extract<ChainOptions, typeof options>>( data, options, ) } function internalParseEntries< EntrySkeleton extends EntrySkeletonType, Locales extends LocaleCode, Options extends ChainOptions, >( data: unknown, options: Options, ): EntryCollection<EntrySkeleton, ModifiersFromOptions<Options>, Locales> { const { withoutLinkResolution, withoutUnresolvableLinks } = options return resolveCircular(data, { resolveLinks: !withoutLinkResolution, removeUnresolved: withoutUnresolvableLinks ?? false, }) } function getConceptScheme<Locales extends LocaleCode>( id: string, query: Record<string, any> = {}, ): Promise<ConceptScheme<Locales>> { return internalGetConceptScheme<Locales>(id, query) } async function internalGetConceptScheme<Locales extends LocaleCode>( id: string, query: Record<string, any> = {}, ): Promise<ConceptScheme<Locales>> { try { return get({ context: 'environment', path: `taxonomy/concept-schemes/${id}`, config: createRequestConfig({ query: normalizeSearchParameters(normalizeSelect(query)), }), }) } catch (error) { errorHandler(error) } } function getConceptSchemes<Locales extends LocaleCode>( query: Record<string, any> = {}, ): Promise<ConceptSchemeCollection<Locales>> { return internalGetConceptSchemes<Locales>(query) } async function internalGetConceptSchemes<Locales extends LocaleCode>( query: Record<string, any> = {}, ): Promise<ConceptSchemeCollection<Locales>> { try { return get({ context: 'environment', path: 'taxonomy/concept-schemes', config: createRequestConfig({ query: normalizeSearchParameters(normalizeSelect(query)), }), }) } catch (error) { errorHandler(error) } } function getConcept<Locales extends LocaleCode>( id: string, query: Record<string, any> = {}, ): Promise<Concept<Locales>> { return internalGetConcept<Locales>(id, query) } async function internalGetConcept<Locales extends LocaleCode>( id: string, query: Record<string, any> = {}, ): Promise<Concept<Locales>> { try { return get({ context: 'environment', path: `taxonomy/concepts/${id}`, config: createRequestConfig({ query: normalizeSearchParameters(normalizeSelect(query)), }), }) } catch (error) { errorHandler(error) } } function getConcepts<Locales extends LocaleCode>( query: Record<string, any> = {}, ): Promise<ConceptCollection<Locales>> { return internalGetConcepts<Locales>(query) } async function internalGetConcepts<Locales extends LocaleCode>( query: Record<string, any> = {}, ): Promise<ConceptCollection<Locales>> { try { return get({ context: 'environment', path: 'taxonomy/concepts', config: createRequestConfig({ query: normalizeSearchParameters(normalizeSelect(query)), }), }) } catch (error) { errorHandler(error) } } /* * Switches BaseURL to use /environments path * */ function switchToEnvironment(http: AxiosInstance): void { http.defaults.baseURL = getGlobalOptions().environmentBaseUrl } return { version: __VERSION__, getSpace, getContentType, getContentTypes, getAsset, getAssets, getTag, getTags, getLocales, parseEntries, sync, getEntry, getEntries, getConceptScheme, getConceptSchemes, getConcept, getConcepts, createAssetKey, } as unknown as ContentfulClientApi<undefined> }