diff --git a/.changeset/pr-1304.md b/.changeset/pr-1304.md new file mode 100644 index 000000000..3965535b5 --- /dev/null +++ b/.changeset/pr-1304.md @@ -0,0 +1,6 @@ + +--- +'@sanity/client': major +--- + +fix!: throw when querying drafts perspective with the API-CDN \ No newline at end of file diff --git a/src/config.ts b/src/config.ts index 926d6dd46..b896bc2ac 100644 --- a/src/config.ts +++ b/src/config.ts @@ -41,6 +41,27 @@ export function validateApiPerspective( } } +/** + * Gradient rejects `drafts` and `previewDrafts` on the API-CDN. Stacked + * perspectives that include those values are treated the same. Other stacked + * perspectives still fall back to the Live API with a warning. + * + * @internal + */ +export function perspectiveConflictsWithCdn(perspective: ClientPerspective): boolean { + if (perspective === 'previewDrafts' || perspective === 'drafts') { + return true + } + if (Array.isArray(perspective)) { + return perspective.includes('drafts') || perspective.includes('previewDrafts') + } + return false +} + +/** @internal */ +export const CDN_INCOMPATIBLE_PERSPECTIVE_ERROR = + 'The Sanity client is configured with the `perspective` set to `drafts` or `previewDrafts`, which does not support the API-CDN. Set `useCdn: false`.' + export const initConfig = ( config: Partial, prevConfig: Partial, diff --git a/src/data/dataMethods.ts b/src/data/dataMethods.ts index 5aea554e0..81c097bf8 100644 --- a/src/data/dataMethods.ts +++ b/src/data/dataMethods.ts @@ -2,7 +2,11 @@ import {getDraftId, getVersionFromId, getVersionId, isDraftId} from '@sanity/cli import {type MonoTypeOperatorFunction, Observable} from 'rxjs' import {filter, map} from 'rxjs/operators' -import {validateApiPerspective} from '../config' +import { + CDN_INCOMPATIBLE_PERSPECTIVE_ERROR, + perspectiveConflictsWithCdn, + validateApiPerspective, +} from '../config' import {type FetchRequest, requestOptions} from '../http/requestOptions' import type {ObservableSanityClient, SanityClient} from '../SanityClient' import {stegaClean, type StegaCleaned} from '../stega/stegaClean' @@ -40,7 +44,7 @@ import {getSelection} from '../util/getSelection' import * as validate from '../validators' import * as validators from '../validators' import { - printCdnPreviewDraftsWarning, + printCdnStackedPerspectiveWarning, printCreateVersionWithBaseIdWarning, printDeprecatedUriOptionWarning, printPreviewDraftsDeprecationWarning, @@ -1063,16 +1067,12 @@ export function _prepareRequest(client: Client, options: RequestObservableOption : 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 - ) { + if (useCdn && perspectiveConflictsWithCdn(perspectiveOption)) { + throw new Error(CDN_INCOMPATIBLE_PERSPECTIVE_ERROR) + } + if (useCdn && Array.isArray(perspectiveOption) && perspectiveOption.length > 0) { useCdn = false - printCdnPreviewDraftsWarning() + printCdnStackedPerspectiveWarning() } } diff --git a/src/types.ts b/src/types.ts index 14f189c98..1b9db255a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -126,7 +126,11 @@ type ClientConfigResource = export interface ClientConfig { projectId?: string dataset?: string - /** @defaultValue true */ + /** + * Whether to use the API-CDN. `drafts` and `previewDrafts` cannot be queried + * through the CDN; combining them with `useCdn: true` throws. + * @defaultValue true + */ useCdn?: boolean token?: string diff --git a/src/warnings.ts b/src/warnings.ts index 98eb40e8c..144103281 100644 --- a/src/warnings.ts +++ b/src/warnings.ts @@ -17,8 +17,8 @@ export const printCdnWarning = createWarningPrinter([ `\`useCdn: false\` to use the Live API. Note: You may incur higher costs using the live API.`, ]) -export const printCdnPreviewDraftsWarning = createWarningPrinter([ - `The Sanity client is configured with the \`perspective\` set to \`drafts\` or \`previewDrafts\`, which doesn't support the API-CDN.`, +export const printCdnStackedPerspectiveWarning = createWarningPrinter([ + `The Sanity client is configured with a stacked \`perspective\`, which doesn't support the API-CDN.`, `The Live API will be used instead. Set \`useCdn: false\` in your configuration to hide this warning.`, ]) diff --git a/test/client/cdnPerspective.test.ts b/test/client/cdnPerspective.test.ts new file mode 100644 index 000000000..29250dc2b --- /dev/null +++ b/test/client/cdnPerspective.test.ts @@ -0,0 +1,205 @@ +import {afterAll, beforeEach, describe, expect, test, vi} from 'vitest' + +import {CDN_INCOMPATIBLE_PERSPECTIVE_ERROR} from '../../src/config' +import {getActiveMock} from '../helpers/mockFetch' +import {createClient} from './helpers' + +const liveHost = 'https://abc123.api.sanity.io' +const cdnHost = 'https://abc123.apicdn.sanity.io' +const draftsQueryPath = '/v1/data/query/foo?query=*&returnQuery=false&perspective=drafts' +const publishedQueryPath = '/v1/data/query/foo?query=*&returnQuery=false&perspective=published' +const rawQueryPath = '/v1/data/query/foo?query=*&returnQuery=false&perspective=raw' +const stackedQueryPath = + '/v1/data/query/foo?query=*&returnQuery=false&perspective=published%2Cdrafts' + +describe('API-CDN and drafts perspective', () => { + const result = [{_id: 'njgNkngskjg', rating: 5}] + const warn = vi.spyOn(console, 'warn') + beforeEach(() => { + warn.mockReset() + }) + afterAll(() => { + warn.mockRestore() + }) + + describe('after: allowed combinations still query', () => { + test('drafts with useCdn false uses the Live API', async () => { + getActiveMock() + .scope(liveHost) + .on('GET', draftsQueryPath) + .respond({status: 200, body: {ms: 123, result}}) + + const client = createClient({ + projectId: 'abc123', + dataset: 'foo', + useCdn: false, + perspective: 'drafts', + }) + expect(await client.fetch('*', {})).toEqual(result) + }) + + test('published with useCdn true uses the API-CDN', async () => { + getActiveMock() + .scope(cdnHost) + .on('GET', publishedQueryPath) + .respond({status: 200, body: {ms: 123, result}}) + + const client = createClient({ + projectId: 'abc123', + dataset: 'foo', + useCdn: true, + perspective: 'published', + }) + expect(await client.fetch('*', {})).toEqual(result) + }) + + test('raw with useCdn true uses the API-CDN', async () => { + getActiveMock() + .scope(cdnHost) + .on('GET', rawQueryPath) + .respond({status: 200, body: {ms: 123, result}}) + + const client = createClient({ + projectId: 'abc123', + dataset: 'foo', + useCdn: true, + perspective: 'raw', + }) + expect(await client.fetch('*', {})).toEqual(result) + }) + + test('drafts with useCdn true still queries when fetch overrides useCdn to false', async () => { + getActiveMock() + .scope(liveHost) + .on('GET', draftsQueryPath) + .respond({status: 200, body: {ms: 123, result}}) + + const client = createClient({ + projectId: 'abc123', + dataset: 'foo', + useCdn: true, + perspective: 'drafts', + }) + expect(await client.fetch('*', {}, {useCdn: false})).toEqual(result) + }) + + test('stacked perspectives without drafts still fall back to the Live API when useCdn is true', async () => { + getActiveMock() + .scope(liveHost) + .on('GET', '/v1/data/query/foo?query=*&returnQuery=false&perspective=published%2Crrel123') + .respond({status: 200, body: {ms: 123, result}}) + + const client = createClient({ + projectId: 'abc123', + dataset: 'foo', + useCdn: true, + perspective: ['published', 'rrel123'], + }) + expect(await client.fetch('*', {})).toEqual(result) + }) + + test('stacked perspectives with useCdn false use the Live API', async () => { + getActiveMock() + .scope(liveHost) + .on('GET', stackedQueryPath) + .respond({status: 200, body: {ms: 123, result}}) + + const client = createClient({ + projectId: 'abc123', + dataset: 'foo', + useCdn: false, + perspective: ['published', 'drafts'], + }) + expect(await client.fetch('*', {})).toEqual(result) + }) + }) + + describe('after: incompatible combinations throw instead of warning', () => { + test('constructing the client with drafts and useCdn true does not throw', () => { + expect(() => + createClient({ + projectId: 'abc123', + dataset: 'foo', + useCdn: true, + perspective: 'drafts', + }), + ).not.toThrow() + }) + + test('fetch throws when config has drafts and useCdn true', () => { + const client = createClient({ + projectId: 'abc123', + dataset: 'foo', + useCdn: true, + perspective: 'drafts', + }) + expect(() => client.fetch('*', {})).toThrow(CDN_INCOMPATIBLE_PERSPECTIVE_ERROR) + expect(warn).not.toHaveBeenCalledWith( + expect.stringContaining('The Live API will be used instead'), + ) + }) + + test('fetch throws when config has previewDrafts and useCdn true', () => { + const client = createClient({ + projectId: 'abc123', + dataset: 'foo', + useCdn: true, + perspective: 'previewDrafts', + }) + expect(() => client.fetch('*', {})).toThrow(CDN_INCOMPATIBLE_PERSPECTIVE_ERROR) + }) + + test('fetch throws when drafts is combined with the default useCdn true', () => { + const client = createClient({ + projectId: 'abc123', + dataset: 'foo', + perspective: 'drafts', + }) + expect(() => client.fetch('*', {})).toThrow(CDN_INCOMPATIBLE_PERSPECTIVE_ERROR) + }) + + test('fetch throws when a drafts perspective override is used on a CDN client', () => { + const client = createClient({ + projectId: 'abc123', + dataset: 'foo', + useCdn: true, + }) + expect(() => client.fetch('*', {}, {perspective: 'drafts'})).toThrow( + CDN_INCOMPATIBLE_PERSPECTIVE_ERROR, + ) + }) + + test('fetch throws when a previewDrafts perspective override is used on a CDN client', () => { + const client = createClient({ + projectId: 'abc123', + dataset: 'foo', + useCdn: true, + }) + expect(() => client.fetch('*', {}, {perspective: 'previewDrafts'})).toThrow( + CDN_INCOMPATIBLE_PERSPECTIVE_ERROR, + ) + }) + + test('fetch throws when a stack including drafts is used with useCdn true', () => { + const client = createClient({ + projectId: 'abc123', + dataset: 'foo', + useCdn: true, + perspective: ['published', 'drafts'], + }) + expect(() => client.fetch('*', {})).toThrow(CDN_INCOMPATIBLE_PERSPECTIVE_ERROR) + }) + + test('fetch throws when useCdn is overridden to true on a drafts client', () => { + const client = createClient({ + projectId: 'abc123', + dataset: 'foo', + useCdn: false, + perspective: 'drafts', + }) + expect(() => client.fetch('*', {}, {useCdn: true})).toThrow( + CDN_INCOMPATIBLE_PERSPECTIVE_ERROR, + ) + }) + }) +}) diff --git a/test/client/config.test.ts b/test/client/config.test.ts index 6e366e27e..eb813ed1e 100644 --- a/test/client/config.test.ts +++ b/test/client/config.test.ts @@ -6,6 +6,7 @@ import { import {firstValueFrom} from 'rxjs' import {describe, expect, test} from 'vitest' +import {perspectiveConflictsWithCdn} from '../../src/config' import {getActiveMock} from '../helpers/mockFetch' import {apiHost, createClient, getClient, projectHost} from './helpers' @@ -185,6 +186,17 @@ describe('base client', () => { ).toThrow(/Invalid API perspective/) }) + test('perspectiveConflictsWithCdn matches the perspectives Gradient rejects on the API-CDN', () => { + expect(perspectiveConflictsWithCdn('drafts')).toBe(true) + expect(perspectiveConflictsWithCdn('previewDrafts')).toBe(true) + expect(perspectiveConflictsWithCdn('published')).toBe(false) + expect(perspectiveConflictsWithCdn('raw')).toBe(false) + expect(perspectiveConflictsWithCdn(['published'])).toBe(false) + expect(perspectiveConflictsWithCdn(['drafts', 'published'])).toBe(true) + expect(perspectiveConflictsWithCdn(['previewDrafts'])).toBe(true) + expect(perspectiveConflictsWithCdn([])).toBe(false) + }) + test('throws on invalid project ids', () => { expect(() => createClient({projectId: '*foo*'})).toThrow(/projectId.*?can only contain/i) }) diff --git a/test/client/data.test.ts b/test/client/data.test.ts index 7ce395aee..3ba962ae0 100644 --- a/test/client/data.test.ts +++ b/test/client/data.test.ts @@ -330,29 +330,6 @@ describe('data', () => { expect(res[0].rating, 'data should match').toBe(5) }) - test('automatically useCdn false if perspective is previewDrafts', async () => { - getActiveMock() - .scope('https://abc123.api.sanity.io') - .on('GET', `/v1/data/query/foo?query=*&returnQuery=false&perspective=previewDrafts`) - .respond({ - status: 200, - body: { - ms: 123, - result, - }, - }) - - const client = createClient({ - projectId: 'abc123', - dataset: 'foo', - useCdn: true, - perspective: 'previewDrafts', - }) - const res = await client.fetch('*', {}) - expect(res.length, 'length should match').toBe(1) - expect(res[0].rating, 'data should match').toBe(5) - }) - test('can query for documents with resultSourceMap and perspective using the third client.fetch parameter', async () => { getActiveMock() .scope(projectHost()) @@ -398,24 +375,6 @@ describe('data', () => { expect(res[0].rating, 'data should match').toBe(5) }) - test('setting a perspective previewDrafts override on client.fetch sets useCdn to false', async () => { - getActiveMock() - .scope('https://abc123.api.sanity.io') - .on('GET', `/v1/data/query/foo?query=*&returnQuery=false&perspective=previewDrafts`) - .respond({ - status: 200, - body: { - ms: 123, - result, - }, - }) - - const client = createClient({projectId: 'abc123', dataset: 'foo', useCdn: true}) - const res = await client.fetch('*', {}, {perspective: 'previewDrafts'}) - expect(res.length, 'length should match').toBe(1) - expect(res[0].rating, 'data should match').toBe(5) - }) - test('can query with a variant id set in the client config', async () => { getActiveMock() .scope(projectHost())