diff --git a/src/buildQueryURL.ts b/src/buildQueryURL.ts index 3bad3f42..a7a40e72 100644 --- a/src/buildQueryURL.ts +++ b/src/buildQueryURL.ts @@ -1,4 +1,4 @@ -import { IterableElement, ValueOf } from 'type-fest' +import { ValueOf } from 'type-fest' import { castArray } from './lib/castArray' @@ -111,23 +111,13 @@ const RENAMED_PARAMS = { accessToken: 'access_token', } as const -/** - * Parameter keys in this list are not actual Prismic REST API V2 parameters. - * They are used for other API inputs and functionality. - * - * These parameters are *not* included in URL builder products. - * - * This list should match parameters included in `BuildQueryURLParams`. - */ -const NON_PARAM_ARGS = ['ref', 'predicates'] as const - /** * A valid parameter name for the Prismic REST API V2. */ type ValidParamName = | Exclude< keyof QueryParams, - keyof typeof RENAMED_PARAMS | IterableElement + keyof typeof RENAMED_PARAMS | keyof BuildQueryURLParams > | ValueOf @@ -142,7 +132,7 @@ type ValidParamName = const castOrderingToString = (ordering: Ordering | string): string => typeof ordering === 'string' ? ordering - : [ordering.field, ordering.direction].join(' ') + : [ordering.field, ordering.direction].filter(Boolean).join(' ') export type BuildQueryURLArgs = QueryParams & BuildQueryURLParams @@ -186,19 +176,15 @@ export const buildQueryURL = ( let value: string | string[] | null | undefined - switch (name) { - case 'orderings': { - const scopedValue = params[name] - - if (scopedValue) { - const v = castArray(scopedValue) - .map((ordering) => castOrderingToString(ordering)) - .join(',') + if (name === 'orderings') { + const scopedValue = params[name] - value = `[${v}]` - } + if (scopedValue) { + const v = castArray(scopedValue) + .map((ordering) => castOrderingToString(ordering)) + .join(',') - break + value = `[${v}]` } } diff --git a/src/client.ts b/src/client.ts index 1ab2095e..2ce67bbe 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,10 +1,11 @@ -import { castArray } from './lib/castArray' +import { appendPredicates } from './lib/appendPredicates' import { getCookie } from './lib/getCookie' +import { orElseThrow } from './lib/orElseThrow' import { Document, Query, Ref, Repository } from './types' import { buildQueryURL, BuildQueryURLArgs } from './buildQueryURL' import * as cookie from './cookie' -import * as predicate from './predicates' +import * as predicate from './predicate' interface HttpRequest { headers: { @@ -12,12 +13,13 @@ interface HttpRequest { } } -type RefIdOrFn = string | (() => string | undefined) +type RefStringOrFn = string | (() => string | undefined) + type Fetch = typeof fetch type ClientConfig = { accessToken?: string - ref?: RefIdOrFn + ref?: RefStringOrFn fetch?: Fetch } & Omit @@ -27,32 +29,28 @@ type GetAllParams = { const MAX_PAGE_SIZE = 100 -const concatCastArray = (...elements: (T | T[])[]): T[] => - elements.map((element) => castArray(element)).flat() +const typePredicate = (documentType: string): string => + predicate.at('document.type', documentType) -const appendPredicates = (...predicates: string[]) => ( - params: Partial = {}, -): Partial => ({ - ...params, - predicates: concatCastArray(params?.predicates ?? [], ...predicates), -}) +const tagsPredicate = (tags: string | string[]): string => + predicate.at('document.tags', tags) -const orElseThrow = (a: T | null | undefined, error: Error): T => { - if (a == null) { - throw error - } +const firstResult = ( + queryResponse: Query, +): TDocument => queryResponse.results[0] - return a -} +export const createClient = ( + ...args: ConstructorParameters +): Client => new Client(...args) export class Client { - private endpoint: string - private accessToken?: string - private ref?: RefIdOrFn - private fetchFn: Fetch - private httpRequest?: HttpRequest - private params?: Omit - private internalEnableAutomaticPreviews: boolean + endpoint: string + accessToken?: string + ref?: RefStringOrFn + fetchFn: Fetch + httpRequest?: HttpRequest + params?: Omit + private autoPreviewsEnabled: boolean constructor(endpoint: string, options: ClientConfig = {}) { const { ref, accessToken, ...params } = options @@ -61,31 +59,30 @@ export class Client { this.accessToken = accessToken this.ref = ref this.params = params - this.internalEnableAutomaticPreviews = true + this.autoPreviewsEnabled = true if (options.fetch) { this.fetchFn = options.fetch + } else if (typeof globalThis.fetch === 'function') { + this.fetchFn = globalThis.fetch } else { - if (typeof fetch === 'undefined') { - throw new Error( - 'A fetch implementation was not provided. In environments where fetch is not available (including Node.js), a fetch implementation must be provided via a polyfill or the `fetch` option.', - ) - } else { - this.fetchFn = globalThis.fetch - } + throw new Error( + 'A fetch implementation was not provided. In environments where fetch is not available (including Node.js), a fetch implementation must be provided via a polyfill or the `fetch` option.', + ) } } - enableAutomaticPreviews(): void { - this.internalEnableAutomaticPreviews = true + enableAutoPreviews(): void { + this.autoPreviewsEnabled = true } - enableAutomaticPreviewsFromReq(req: R): void { + enableAutoPreviewsFromReq(req: R): void { this.httpRequest = req + this.autoPreviewsEnabled = true } - disableAutomaticPreviews(): void { - this.internalEnableAutomaticPreviews = false + disableAutoPreviews(): void { + this.autoPreviewsEnabled = false } query = this.get @@ -109,7 +106,7 @@ export class Client { let page = result.page let documents = result.results - while (page <= result.total_pages && documents.length < limit) { + while (page < result.total_pages && documents.length < limit) { page += 1 const result = await this.get({ ...resolvedParams, page }) documents = [...documents, ...result.results] @@ -122,11 +119,12 @@ export class Client { id: string, params?: Partial, ): Promise { - const generateParams = appendPredicates(predicate.at('document.id', id)) - const result = await this.get(generateParams(params)) + const result = await this.get( + appendPredicates(predicate.at('document.id', id))(params), + ) return orElseThrow( - result.results[0], + firstResult(result), new Error(`A document with ID "${id}" could not be found.`), ) } @@ -136,14 +134,15 @@ export class Client { uid: string, params?: Partial, ): Promise { - const generateParams = appendPredicates( - predicate.at('document.type', documentType), - predicate.at('document.uid', uid), + const result = await this.get( + appendPredicates( + typePredicate(documentType), + predicate.at('document.uid', uid), + )(params), ) - const result = await this.get(generateParams(params)) return orElseThrow( - result.results[0], + firstResult(result), new Error( `A document of type "${documentType}" with UID "${uid}" could not be found.`, ), @@ -154,13 +153,12 @@ export class Client { documentType: string, params?: Partial, ): Promise { - const generateParams = appendPredicates( - predicate.at('document.type', documentType), + const result = await this.get( + appendPredicates(typePredicate(documentType))(params), ) - const result = await this.get(generateParams(params)) return orElseThrow( - result.results[0], + firstResult(result), new Error(`A document of type "${documentType}" could not be found.`), ) } @@ -169,77 +167,85 @@ export class Client { documentType: string, params?: Partial, ): Promise> { - const generateParams = appendPredicates( - predicate.at('document.type', documentType), + return await this.get( + appendPredicates(typePredicate(documentType))(params), ) - - return await this.get(generateParams(params)) } async getAllByType( documentType: string, params?: Partial, ): Promise { - const generateParams = appendPredicates( - predicate.at('document.type', documentType), + return await this.getAll( + appendPredicates(typePredicate(documentType))(params), ) - - return await this.getAll(generateParams(params)) } async getByTag( tag: string, params?: Partial, ): Promise> { - const generateParams = appendPredicates(predicate.at('document.tags', tag)) - - return await this.get(generateParams(params)) + return await this.get( + appendPredicates(tagsPredicate(tag))(params), + ) } async getAllByTag( tag: string, params?: Partial, ): Promise { - const generateParams = appendPredicates(predicate.at('document.tags', tag)) - - return await this.getAll(generateParams(params)) + return await this.getAll( + appendPredicates(tagsPredicate(tag))(params), + ) } async getByTags( tags: string[], params?: Partial, ): Promise> { - const generateParams = appendPredicates(predicate.at('document.tags', tags)) - - return await this.get(generateParams(params)) + return await this.get( + appendPredicates(tagsPredicate(tags))(params), + ) } async getAllByTags( tags: string[], params?: Partial, ): Promise { - const generateParams = appendPredicates(predicate.at('document.tags', tags)) + return await this.getAll( + appendPredicates(tagsPredicate(tags))(params), + ) + } + + async getRefs(): Promise { + const res = await this.fetch(this.endpoint) - return await this.getAll(generateParams(params)) + return res.refs } - async buildQueryURL(params?: Partial): Promise { - const ref = params?.ref ?? (await this.getResolvedRefString()) + async getRefById(id: string): Promise { + const refs = await this.getRefs() + const ref = refs.find((ref) => ref.id === id) - return buildQueryURL(this.endpoint, { - ...this.params, - ...params, - ref, - }) + if (!ref) { + throw new Error('Ref could not be found.') + } + + return ref } - async getRefs(): Promise { - const res = await this.fetch(this.endpoint) + async getRefByLabel(label: string): Promise { + const refs = await this.getRefs() + const ref = refs.find((ref) => ref.label === label) - return res.refs + if (!ref) { + throw new Error('Ref could not be found.') + } + + return ref } - private async getMasterRef(): Promise { + async getMasterRef(): Promise { const refs = await this.getRefs() const masterRef = refs.find((ref) => ref.isMasterRef) @@ -250,6 +256,16 @@ export class Client { return masterRef } + async buildQueryURL(params?: Partial): Promise { + const ref = params?.ref ?? (await this.getResolvedRefString()) + + return buildQueryURL(this.endpoint, { + ...this.params, + ...params, + ref, + }) + } + private getPreviewRefString(): string | undefined { if (typeof globalThis.document !== 'undefined') { return getCookie(cookie.preview, globalThis.document.cookie) @@ -259,7 +275,7 @@ export class Client { } private async getResolvedRefString(): Promise { - if (this.internalEnableAutomaticPreviews) { + if (this.autoPreviewsEnabled) { const previewRef = this.getPreviewRefString() if (previewRef) { @@ -275,7 +291,7 @@ export class Client { if (typeof res === 'string') { return res } - } else if (typeof thisRefIdOrFn === 'string') { + } else if (thisRefIdOrFn) { return thisRefIdOrFn } @@ -285,13 +301,9 @@ export class Client { } private buildRequestOptions(): RequestInit { - const headers = new Headers() - - if (this.accessToken) { - headers.set('Authorization', `Token ${this.accessToken}`) - } - - return { headers } + return this.accessToken + ? { headers: { Authorization: `Token ${this.accessToken}` } } + : {} } private async fetch( @@ -301,22 +313,16 @@ export class Client { const baseOptions = this.buildRequestOptions() const res = await this.fetchFn(uri, { ...baseOptions, ...options }) - if (res.ok) { + if (res.status === 200) { // We can assume Prismic REST API responses will have a `application/json` // Content Type. return await res.json() + } else if (res.status === 401) { + throw new Error( + '401 Unauthorized: A valid access token is required to access this repository.', + ) } else { - switch (res.status) { - case 401: { - throw new Error( - '401 Unauthorized: A valid access token is required to access this repository.', - ) - } - - default: { - throw new Error(`${res.status}: An unknown network error occured.`) - } - } + throw new Error(`${res.status}: An unknown network error occured.`) } } } diff --git a/src/index.ts b/src/index.ts index e69de29b..d10fda4b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -0,0 +1,4 @@ +export { createClient, Client } from './client' +export { buildQueryURL } from './buildQueryURL' +export * as cookie from './cookie' +export * as types from './types' diff --git a/src/lib/appendPredicates.ts b/src/lib/appendPredicates.ts new file mode 100644 index 00000000..ef67067b --- /dev/null +++ b/src/lib/appendPredicates.ts @@ -0,0 +1,10 @@ +interface WithPredicates { + predicates?: string | string[] +} + +export const appendPredicates = ( + ...predicates: string[] +) => (params: T = {} as T): T => ({ + ...params, + predicates: [params.predicates || [], ...predicates].flat(), +}) diff --git a/src/lib/orElseThrow.ts b/src/lib/orElseThrow.ts new file mode 100644 index 00000000..d866a521 --- /dev/null +++ b/src/lib/orElseThrow.ts @@ -0,0 +1,7 @@ +export const orElseThrow = (a: T | null | undefined, error: Error): T => { + if (a == null) { + throw error + } + + return a +} diff --git a/src/predicate.ts b/src/predicate.ts index 9bb26199..498acbe4 100644 --- a/src/predicate.ts +++ b/src/predicate.ts @@ -9,8 +9,6 @@ const formatValue = ( ? `${value.getTime()}` : `${value}` -type DefaultPredicateArgs = [value: string | number | (string | number)[]] - // eslint-disable-next-line @typescript-eslint/no-explicit-any const pathWithArgsPredicate = (name: string) => /** @@ -29,6 +27,8 @@ const argsPredicate = (name: string) => ( ...args: Args ): string => pathWithArgsPredicate(name)('', ...args) +type DefaultPredicateArgs = [value: string | number | (string | number)[]] + /** * The `at` predicate checks that the path matches the described value exactly. It takes a single value for a field or an array (only for tags). * diff --git a/test/predicates/any.test.ts b/test/predicate/any.test.ts similarity index 100% rename from test/predicates/any.test.ts rename to test/predicate/any.test.ts diff --git a/test/predicates/at.test.ts b/test/predicate/at.test.ts similarity index 100% rename from test/predicates/at.test.ts rename to test/predicate/at.test.ts diff --git a/test/predicates/date.test.ts b/test/predicate/date.test.ts similarity index 100% rename from test/predicates/date.test.ts rename to test/predicate/date.test.ts diff --git a/test/predicates/fulltext.test.ts b/test/predicate/fulltext.test.ts similarity index 100% rename from test/predicates/fulltext.test.ts rename to test/predicate/fulltext.test.ts diff --git a/test/predicates/geopoint.test.ts b/test/predicate/geopoint.test.ts similarity index 100% rename from test/predicates/geopoint.test.ts rename to test/predicate/geopoint.test.ts diff --git a/test/predicates/has.test.ts b/test/predicate/has.test.ts similarity index 100% rename from test/predicates/has.test.ts rename to test/predicate/has.test.ts diff --git a/test/predicates/in.test.ts b/test/predicate/in.test.ts similarity index 100% rename from test/predicates/in.test.ts rename to test/predicate/in.test.ts diff --git a/test/predicates/missing.test.ts b/test/predicate/missing.test.ts similarity index 100% rename from test/predicates/missing.test.ts rename to test/predicate/missing.test.ts diff --git a/test/predicates/not.test.ts b/test/predicate/not.test.ts similarity index 100% rename from test/predicates/not.test.ts rename to test/predicate/not.test.ts diff --git a/test/predicates/number.test.ts b/test/predicate/number.test.ts similarity index 100% rename from test/predicates/number.test.ts rename to test/predicate/number.test.ts diff --git a/test/predicates/similar.test.ts b/test/predicate/similar.test.ts similarity index 100% rename from test/predicates/similar.test.ts rename to test/predicate/similar.test.ts