UNPKG

@sanity/client

Version:

Client for retrieving, creating and patching data from Sanity.io

761 lines (682 loc) • 24.1 kB
import {getDraftId, getVersionFromId, getVersionId, isDraftId} from '@sanity/client/csm' import {from, type MonoTypeOperatorFunction, Observable} from 'rxjs' import {combineLatestWith, filter, map} from 'rxjs/operators' import {validateApiPerspective} from '../config' import {requestOptions} from '../http/requestOptions' import type {ObservableSanityClient, SanityClient} from '../SanityClient' import {stegaClean} from '../stega/stegaClean' import type { Action, AllDocumentIdsMutationOptions, AllDocumentsMutationOptions, Any, BaseActionOptions, BaseMutationOptions, CreateVersionAction, DiscardVersionAction, FirstDocumentIdMutationOptions, FirstDocumentMutationOptions, HttpRequest, HttpRequestEvent, IdentifiedSanityDocumentStub, InitializedClientConfig, InitializedStegaConfig, MultipleActionResult, MultipleMutationResult, Mutation, MutationSelection, QueryOptions, RawQueryResponse, ReplaceVersionAction, RequestObservableOptions, RequestOptions, SanityDocument, SingleActionResult, SingleMutationResult, UnpublishVersionAction, } from '../types' import {getSelection} from '../util/getSelection' import * as validate from '../validators' import * as validators from '../validators' import { printCdnPreviewDraftsWarning, printCreateVersionWithBaseIdWarning, printPreviewDraftsDeprecationWarning, } from '../warnings' import {encodeQueryString} from './encodeQueryString' import {ObservablePatch, Patch} from './patch' import {ObservableTransaction, Transaction} from './transaction' type Client = SanityClient | ObservableSanityClient const excludeFalsey = (param: Any, defValue: Any) => { const value = typeof param === 'undefined' ? defValue : param return param === false ? undefined : value } const getMutationQuery = (options: BaseMutationOptions = {}) => { return { dryRun: options.dryRun, returnIds: true, returnDocuments: excludeFalsey(options.returnDocuments, true), visibility: options.visibility || 'sync', autoGenerateArrayKeys: options.autoGenerateArrayKeys, skipCrossDatasetReferenceValidation: options.skipCrossDatasetReferenceValidation, } } const isResponse = (event: Any) => event.type === 'response' const getBody = (event: Any) => event.body const indexBy = (docs: Any[], attr: Any) => docs.reduce((indexed, doc) => { indexed[attr(doc)] = doc return indexed }, Object.create(null)) const getQuerySizeLimit = 11264 /** @internal */ export function _fetch<R, Q>( client: Client, httpRequest: HttpRequest, _stega: InitializedStegaConfig, query: string, _params: Q = {} as Q, options: QueryOptions = {}, ): Observable<RawQueryResponse<R> | R> { const stega = 'stega' in options ? { ...(_stega || {}), ...(typeof options.stega === 'boolean' ? {enabled: options.stega} : options.stega || {}), } : _stega const params = stega.enabled ? stegaClean(_params) : _params const mapResponse = options.filterResponse === false ? (res: Any) => res : (res: Any) => res.result const {cache, next, ...opts} = { // Opt out of setting a `signal` on an internal `fetch` if one isn't provided. // This is necessary in React Server Components to avoid opting out of Request Memoization. useAbortSignal: typeof options.signal !== 'undefined', // Set `resultSourceMap' when stega is enabled, as it's required for encoding. resultSourceMap: stega.enabled ? 'withKeyArraySelector' : options.resultSourceMap, ...options, // Default to not returning the query, unless `filterResponse` is `false`, // or `returnQuery` is explicitly set. `true` is the default in Content Lake, so skip if truthy returnQuery: options.filterResponse === false && options.returnQuery !== false, } const reqOpts = typeof cache !== 'undefined' || typeof next !== 'undefined' ? {...opts, fetch: {cache, next}} : opts const $request = _dataRequest(client, httpRequest, 'query', {query, params}, reqOpts) return stega.enabled ? $request.pipe( combineLatestWith( from( import('../stega/stegaEncodeSourceMap').then( ({stegaEncodeSourceMap}) => stegaEncodeSourceMap, ), ), ), map( ([res, stegaEncodeSourceMap]: [ Any, (typeof import('../stega/stegaEncodeSourceMap'))['stegaEncodeSourceMap'], ]) => { const result = stegaEncodeSourceMap(res.result, res.resultSourceMap, stega) return mapResponse({...res, result}) }, ), ) : $request.pipe(map(mapResponse)) } /** @internal */ export function _getDocument<R extends Record<string, Any>>( client: Client, httpRequest: HttpRequest, id: string, opts: {signal?: AbortSignal; tag?: string; releaseId?: string} = {}, ): Observable<SanityDocument<R> | undefined> { const getDocId = () => { if (!opts.releaseId) { return id } const versionId = getVersionFromId(id) if (!versionId) { if (isDraftId(id)) { throw new Error( `The document ID (\`${id}\`) is a draft, but \`options.releaseId\` is set as \`${opts.releaseId}\``, ) } return getVersionId(id, opts.releaseId) } if (versionId !== opts.releaseId) { throw new Error( `The document ID (\`${id}\`) is already a version of \`${versionId}\` release, but this does not match the provided \`options.releaseId\` (\`${opts.releaseId}\`)`, ) } return id } const docId = getDocId() const options = { uri: _getDataUrl(client, 'doc', docId), json: true, tag: opts.tag, signal: opts.signal, } return _requestObservable<SanityDocument<R> | undefined>(client, httpRequest, options).pipe( filter(isResponse), map((event) => event.body.documents && event.body.documents[0]), ) } /** @internal */ export function _getDocuments<R extends Record<string, Any>>( client: Client, httpRequest: HttpRequest, ids: string[], opts: {signal?: AbortSignal; tag?: string} = {}, ): Observable<(SanityDocument<R> | null)[]> { const options = { uri: _getDataUrl(client, 'doc', ids.join(',')), json: true, tag: opts.tag, signal: opts.signal, } return _requestObservable<(SanityDocument<R> | null)[]>(client, httpRequest, options).pipe( filter(isResponse), map((event: Any) => { const indexed = indexBy(event.body.documents || [], (doc: Any) => doc._id) return ids.map((id) => indexed[id] || null) }), ) } /** @internal */ export function _getReleaseDocuments<R extends Record<string, Any>>( client: ObservableSanityClient | SanityClient, httpRequest: HttpRequest, releaseId: string, opts: BaseMutationOptions = {}, ): Observable<RawQueryResponse<SanityDocument<R>[]>> { return _dataRequest( client, httpRequest, 'query', { query: '*[sanity::partOfRelease($releaseId)]', params: { releaseId, }, }, opts, ) } /** @internal */ export function _createIfNotExists<R extends Record<string, Any>>( client: Client, httpRequest: HttpRequest, doc: IdentifiedSanityDocumentStub<R>, options?: | AllDocumentIdsMutationOptions | AllDocumentsMutationOptions | BaseMutationOptions | FirstDocumentIdMutationOptions | FirstDocumentMutationOptions, ): Observable< SanityDocument<R> | SanityDocument<R>[] | SingleMutationResult | MultipleMutationResult > { validators.requireDocumentId('createIfNotExists', doc) return _create<R>(client, httpRequest, doc, 'createIfNotExists', options) } /** @internal */ export function _createOrReplace<R extends Record<string, Any>>( client: Client, httpRequest: HttpRequest, doc: IdentifiedSanityDocumentStub<R>, options?: | AllDocumentIdsMutationOptions | AllDocumentsMutationOptions | BaseMutationOptions | FirstDocumentIdMutationOptions | FirstDocumentMutationOptions, ): Observable< SanityDocument<R> | SanityDocument<R>[] | SingleMutationResult | MultipleMutationResult > { validators.requireDocumentId('createOrReplace', doc) return _create<R>(client, httpRequest, doc, 'createOrReplace', options) } /** @internal */ export function _createVersion<R extends Record<string, Any>>( client: ObservableSanityClient | SanityClient, httpRequest: HttpRequest, doc: IdentifiedSanityDocumentStub<R>, publishedId: string, options?: BaseActionOptions, ): Observable<SingleActionResult> { validators.requireDocumentId('createVersion', doc) validators.requireDocumentType('createVersion', doc) printCreateVersionWithBaseIdWarning() const createVersionAction: CreateVersionAction = { actionType: 'sanity.action.document.version.create', publishedId, document: doc, } return _action(client, httpRequest, createVersionAction, options) } /** @internal */ export function _createVersionFromBase( client: ObservableSanityClient | SanityClient, httpRequest: HttpRequest, publishedId?: string, baseId?: string, releaseId?: string, ifBaseRevisionId?: string, options?: BaseActionOptions, ): Observable<SingleActionResult> { if (!baseId) { throw new Error('`createVersion()` requires `baseId` when no `document` is provided') } if (!publishedId) { throw new Error('`createVersion()` requires `publishedId` when `baseId` is provided') } validators.validateDocumentId('createVersion', baseId) validators.validateDocumentId('createVersion', publishedId) const createVersionAction: CreateVersionAction = { actionType: 'sanity.action.document.version.create', publishedId, baseId, versionId: releaseId ? getVersionId(publishedId, releaseId) : getDraftId(publishedId), ifBaseRevisionId, } return _action(client, httpRequest, createVersionAction, options) } /** @internal */ export function _delete<R extends Record<string, Any>>( client: Client, httpRequest: HttpRequest, selection: string | MutationSelection, options?: | AllDocumentIdsMutationOptions | AllDocumentsMutationOptions | BaseMutationOptions | FirstDocumentIdMutationOptions | FirstDocumentMutationOptions, ): Observable< SanityDocument<R> | SanityDocument<R>[] | SingleMutationResult | MultipleMutationResult > { return _dataRequest( client, httpRequest, 'mutate', {mutations: [{delete: getSelection(selection)}]}, options, ) } /** @internal */ export function _discardVersion( client: ObservableSanityClient | SanityClient, httpRequest: HttpRequest, versionId: string, purge: boolean = false, options?: BaseActionOptions, ): Observable<SingleActionResult> { const discardVersionAction: DiscardVersionAction = { actionType: 'sanity.action.document.version.discard', versionId, purge, } return _action(client, httpRequest, discardVersionAction, options) } /** @internal */ export function _replaceVersion<R extends Record<string, Any>>( client: ObservableSanityClient | SanityClient, httpRequest: HttpRequest, doc: IdentifiedSanityDocumentStub<R>, options?: BaseActionOptions, ): Observable<SingleActionResult> { validators.requireDocumentId('replaceVersion', doc) validators.requireDocumentType('replaceVersion', doc) const replaceVersionAction: ReplaceVersionAction = { actionType: 'sanity.action.document.version.replace', document: doc, } return _action(client, httpRequest, replaceVersionAction, options) } /** @internal */ export function _unpublishVersion( client: ObservableSanityClient | SanityClient, httpRequest: HttpRequest, versionId: string, publishedId: string, options?: BaseActionOptions, ): Observable<SingleActionResult> { const unpublishVersionAction: UnpublishVersionAction = { actionType: 'sanity.action.document.version.unpublish', versionId, publishedId, } return _action(client, httpRequest, unpublishVersionAction, options) } /** @internal */ export function _mutate<R extends Record<string, Any>>( client: Client, httpRequest: HttpRequest, mutations: Mutation<R>[] | Patch | ObservablePatch | Transaction | ObservableTransaction, options?: | AllDocumentIdsMutationOptions | AllDocumentsMutationOptions | BaseMutationOptions | FirstDocumentIdMutationOptions | FirstDocumentMutationOptions, ): Observable< SanityDocument<R> | SanityDocument<R>[] | SingleMutationResult | MultipleMutationResult > { let mut: Mutation | Mutation[] if (mutations instanceof Patch || mutations instanceof ObservablePatch) { mut = {patch: mutations.serialize()} } else if (mutations instanceof Transaction || mutations instanceof ObservableTransaction) { mut = mutations.serialize() } else { mut = mutations } const muts = Array.isArray(mut) ? mut : [mut] const transactionId = (options && options.transactionId) || undefined return _dataRequest(client, httpRequest, 'mutate', {mutations: muts, transactionId}, options) } /** * @internal */ export function _action( client: Client, httpRequest: HttpRequest, actions: Action | Action[], options?: BaseActionOptions, ): Observable<SingleActionResult | MultipleActionResult> { const acts = Array.isArray(actions) ? actions : [actions] const transactionId = (options && options.transactionId) || undefined const skipCrossDatasetReferenceValidation = (options && options.skipCrossDatasetReferenceValidation) || undefined const dryRun = (options && options.dryRun) || undefined return _dataRequest( client, httpRequest, 'actions', {actions: acts, transactionId, skipCrossDatasetReferenceValidation, dryRun}, options, ) } /** * @internal */ export function _dataRequest( client: Client, httpRequest: HttpRequest, endpoint: string, body: Any, options: Any = {}, ): Any { const isMutation = endpoint === 'mutate' const isAction = endpoint === 'actions' const isQuery = endpoint === 'query' // Check if the query string is within a configured threshold, // in which case we can use GET. Otherwise, use POST. const strQuery = isMutation || isAction ? '' : encodeQueryString(body) const useGet = !isMutation && !isAction && strQuery.length < getQuerySizeLimit const stringQuery = useGet ? strQuery : '' const returnFirst = options.returnFirst const {timeout, token, tag, headers, returnQuery, lastLiveEventId, cacheMode} = options const uri = _getDataUrl(client, endpoint, stringQuery) const reqOptions = { method: useGet ? 'GET' : 'POST', uri: uri, json: true, body: useGet ? undefined : body, query: isMutation && getMutationQuery(options), timeout, headers, token, tag, returnQuery, perspective: options.perspective, resultSourceMap: options.resultSourceMap, lastLiveEventId: Array.isArray(lastLiveEventId) ? lastLiveEventId[0] : lastLiveEventId, cacheMode: cacheMode, canUseCdn: isQuery, signal: options.signal, fetch: options.fetch, useAbortSignal: options.useAbortSignal, useCdn: options.useCdn, } return _requestObservable(client, httpRequest, reqOptions).pipe( filter(isResponse), map(getBody), map((res) => { if (!isMutation) { return res } // Should we return documents? const results = res.results || [] if (options.returnDocuments) { return returnFirst ? results[0] && results[0].document : results.map((mut: Any) => mut.document) } // Return a reduced subset const key = returnFirst ? 'documentId' : 'documentIds' const ids = returnFirst ? results[0] && results[0].id : results.map((mut: Any) => mut.id) return { transactionId: res.transactionId, results: results, [key]: ids, } }), ) } /** * @internal */ export function _create<R extends Record<string, Any>>( client: Client, httpRequest: HttpRequest, doc: Any, op: Any, options: Any = {}, ): Observable< SanityDocument<R> | SanityDocument<R>[] | SingleMutationResult | MultipleMutationResult > { const mutation = {[op]: doc} const opts = Object.assign({returnFirst: true, returnDocuments: true}, options) return _dataRequest(client, httpRequest, 'mutate', {mutations: [mutation]}, opts) } const hasDataConfig = (client: Client) => (client.config().dataset !== undefined && client.config().projectId !== undefined) || client.config()['~experimental_resource'] !== undefined const isQuery = (client: Client, uri: string) => hasDataConfig(client) && uri.startsWith(_getDataUrl(client, 'query')) const isMutate = (client: Client, uri: string) => hasDataConfig(client) && uri.startsWith(_getDataUrl(client, 'mutate')) const isDoc = (client: Client, uri: string) => hasDataConfig(client) && uri.startsWith(_getDataUrl(client, 'doc', '')) const isListener = (client: Client, uri: string) => hasDataConfig(client) && uri.startsWith(_getDataUrl(client, 'listen')) const isHistory = (client: Client, uri: string) => hasDataConfig(client) && uri.startsWith(_getDataUrl(client, 'history', '')) const isData = (client: Client, uri: string) => uri.startsWith('/data/') || isQuery(client, uri) || isMutate(client, uri) || isDoc(client, uri) || isListener(client, uri) || isHistory(client, uri) /** * @internal */ export function _requestObservable<R>( client: Client, httpRequest: HttpRequest, options: RequestObservableOptions, ): Observable<HttpRequestEvent<R>> { const uri = options.url || (options.uri as string) const config = client.config() // If the `canUseCdn`-option is not set we detect it automatically based on the method + URL. // Only the /data endpoint is currently available through API-CDN. const canUseCdn = typeof options.canUseCdn === 'undefined' ? ['GET', 'HEAD'].indexOf(options.method || 'GET') >= 0 && isData(client, uri) : options.canUseCdn let useCdn = (options.useCdn ?? config.useCdn) && canUseCdn const tag = options.tag && config.requestTagPrefix ? [config.requestTagPrefix, options.tag].join('.') : options.tag || config.requestTagPrefix if (tag && options.tag !== null) { options.query = {tag: validate.requestTag(tag), ...options.query} } // GROQ query-only parameters if (['GET', 'HEAD', 'POST'].indexOf(options.method || 'GET') >= 0 && isQuery(client, uri)) { const resultSourceMap = options.resultSourceMap ?? config.resultSourceMap if (resultSourceMap !== undefined && resultSourceMap !== false) { options.query = {resultSourceMap, ...options.query} } const perspectiveOption = options.perspective || config.perspective if (typeof perspectiveOption !== 'undefined') { if (perspectiveOption === 'previewDrafts') { printPreviewDraftsDeprecationWarning() } validateApiPerspective(perspectiveOption) options.query = { perspective: Array.isArray(perspectiveOption) ? perspectiveOption.join(',') : perspectiveOption, ...options.query, } // If the perspective is set to `drafts` or multiple perspectives we can't use the CDN, the API will throw if ( ((Array.isArray(perspectiveOption) && perspectiveOption.length > 0) || // previewDrafts was renamed to drafts, but keep for backwards compat perspectiveOption === 'previewDrafts' || perspectiveOption === 'drafts') && useCdn ) { useCdn = false printCdnPreviewDraftsWarning() } } if (options.lastLiveEventId) { options.query = {...options.query, lastLiveEventId: options.lastLiveEventId} } if (options.returnQuery === false) { options.query = {returnQuery: 'false', ...options.query} } if (useCdn && options.cacheMode == 'noStale') { options.query = {cacheMode: 'noStale', ...options.query} } } const reqOptions = requestOptions( config, Object.assign({}, options, { url: _getUrl(client, uri, useCdn), }), ) as RequestOptions const request = new Observable<HttpRequestEvent<R>>((subscriber) => httpRequest(reqOptions, config.requester!).subscribe(subscriber), ) return options.signal ? request.pipe(_withAbortSignal(options.signal)) : request } /** * @internal */ export function _request<R>(client: Client, httpRequest: HttpRequest, options: Any): Observable<R> { const observable = _requestObservable<R>(client, httpRequest, options).pipe( filter((event: Any) => event.type === 'response'), map((event: Any) => event.body), ) return observable } /** * @internal */ export function _getDataUrl(client: Client, operation: string, path?: string): string { const config = client.config() if (config['~experimental_resource']) { validators.resourceConfig(config) const resourceBase = resourceDataBase(config) const uri = path !== undefined ? `${operation}/${path}` : operation return `${resourceBase}/${uri}`.replace(/\/($|\?)/, '$1') } const catalog = validators.hasDataset(config) const baseUri = `/${operation}/${catalog}` const uri = path !== undefined ? `${baseUri}/${path}` : baseUri return `/data${uri}`.replace(/\/($|\?)/, '$1') } /** * @internal */ export function _getUrl(client: Client, uri: string, canUseCdn = false): string { const {url, cdnUrl} = client.config() const base = canUseCdn ? cdnUrl : url return `${base}/${uri.replace(/^\//, '')}` } /** * @internal */ function _withAbortSignal<T>(signal: AbortSignal): MonoTypeOperatorFunction<T> { return (input) => { return new Observable((observer) => { const abort = () => observer.error(_createAbortError(signal)) if (signal && signal.aborted) { abort() return } const subscription = input.subscribe(observer) signal.addEventListener('abort', abort) return () => { signal.removeEventListener('abort', abort) subscription.unsubscribe() } }) } } // DOMException is supported on most modern browsers and Node.js 18+. // @see https://developer.mozilla.org/en-US/docs/Web/API/DOMException#browser_compatibility const isDomExceptionSupported = Boolean(globalThis.DOMException) /** * @internal * @param signal - The abort signal to use. * Original source copied from https://github.com/sindresorhus/ky/blob/740732c78aad97e9aec199e9871bdbf0ae29b805/source/errors/DOMException.ts * TODO: When targeting Node.js 18, use `signal.throwIfAborted()` (https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/throwIfAborted) */ function _createAbortError(signal?: AbortSignal) { /* NOTE: Use DomException with AbortError name as specified in MDN docs (https://developer.mozilla.org/en-US/docs/Web/API/AbortController/abort) > When abort() is called, the fetch() promise rejects with an Error of type DOMException, with name AbortError. */ if (isDomExceptionSupported) { return new DOMException(signal?.reason ?? 'The operation was aborted.', 'AbortError') } // DOMException not supported. Fall back to use of error and override name. const error = new Error(signal?.reason ?? 'The operation was aborted.') error.name = 'AbortError' return error } const resourceDataBase = (config: InitializedClientConfig): string => { if (!config['~experimental_resource']) { throw new Error('`resource` must be provided to perform resource queries') } const {type, id} = config['~experimental_resource'] switch (type) { case 'dataset': { const segments = id.split('.') if (segments.length !== 2) { throw new Error('Dataset ID must be in the format "project.dataset"') } return `/projects/${segments[0]}/datasets/${segments[1]}` } case 'canvas': { return `/canvases/${id}` } case 'media-library': { return `/media-libraries/${id}` } case 'dashboard': { return `/dashboards/${id}` } default: // @ts-expect-error - handle all supported resource types throw new Error(`Unsupported resource type: ${type.toString()}`) } }