@prismicio/client
Version:
The official JavaScript + TypeScript client library for Prismic
953 lines (864 loc) • 24.6 kB
text/typescript
import { devMsg } from "./lib/devMsg"
import { pLimit } from "./lib/pLimit"
import type { ResponseLike } from "./lib/request"
import { type RequestInitLike, request } from "./lib/request"
import {
resolveMigrationContentRelationship,
resolveMigrationDocumentData,
} from "./lib/resolveMigrationDocumentData"
import type {
Asset,
PatchAssetParams,
PostAssetParams,
PostAssetResult,
} from "./types/api/asset/asset"
import type {
AssetTag,
GetAssetTagsResult,
PostAssetTagResult,
} from "./types/api/asset/tag"
import { type PostDocumentResult } from "./types/api/migration/document"
import type { PrismicMigrationAsset } from "./types/migration/Asset"
import type {
MigrationDocument,
PendingPrismicDocument,
PrismicMigrationDocument,
} from "./types/migration/Document"
import type { PrismicDocument } from "./types/value/document"
import { ForbiddenError } from "./errors/ForbiddenError"
import { InvalidDataError } from "./errors/InvalidDataError"
import { NotFoundError } from "./errors/NotFoundError"
import { PrismicError } from "./errors/PrismicError"
import { name, version } from "../package.json"
import { Client } from "./Client"
import type { ClientConfig, FetchParams } from "./Client"
import type { Migration } from "./Migration"
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import type { createMigration } from "./createMigration"
const CLIENT_IDENTIFIER = `${name.replace("@", "").replace("/", "-")}/${version}`
/**
* Extracts one or more Prismic document types that match a given Prismic
* document type. If no matches are found, no extraction is performed and the
* union of all provided Prismic document types are returned.
*
* @typeParam TDocuments - Prismic document types from which to extract.
* @typeParam TDocumentType - Type(s) to match `TDocuments` against.
*/
type ExtractDocumentType<
TDocuments extends { type: string },
TDocumentType extends TDocuments["type"],
> =
Extract<TDocuments, { type: TDocumentType }> extends never
? TDocuments
: Extract<TDocuments, { type: TDocumentType }>
/**
* Utility type to construct events reported by the migration process.
*/
type MigrateReporterEvent<
TType extends string,
TData = never,
> = TData extends never
? { type: TType }
: {
type: TType
data: TData
}
/**
* A map of event types and their data reported by the migration process.
*/
type MigrateReporterEventMap = {
start: {
pending: {
documents: number
assets: number
}
}
end: {
migrated: {
documents: number
assets: number
}
}
"assets:creating": {
current: number
remaining: number
total: number
asset: PrismicMigrationAsset
}
"assets:created": {
created: number
}
"documents:masterLocale": {
masterLocale: string
}
"documents:creating": {
current: number
remaining: number
total: number
document: PrismicMigrationDocument
}
"documents:created": {
created: number
}
"documents:updating": {
current: number
remaining: number
total: number
document: PrismicMigrationDocument
}
"documents:updated": {
updated: number
}
}
/**
* Available event types reported by the migration process.
*/
type MigrateReporterEventTypes = keyof MigrateReporterEventMap
/**
* All events reported by the migration process. Events can be listened to by
* providing a `reporter` function to the `migrate` method.
*/
export type MigrateReporterEvents = {
[K in MigrateReporterEventTypes]: MigrateReporterEvent<
K,
MigrateReporterEventMap[K]
>
}[MigrateReporterEventTypes]
/**
* Additional parameters for creating an asset in the Prismic media library.
*/
export type CreateAssetParams = {
/**
* Asset notes.
*/
notes?: string
/**
* Asset credits.
*/
credits?: string
/**
* Asset alt text.
*/
alt?: string
/**
* Asset tags.
*/
tags?: string[]
}
/**
* Configuration for clients that determine how content is queried.
*/
export type WriteClientConfig = {
/**
* A Prismic write token that allows writing content to the repository.
*/
writeToken: string
/**
* The Prismic Asset API endpoint.
*
* @defaultValue `"https://asset-api.prismic.io/"`
*
* @see Prismic Asset API technical reference: {@link https://prismic.io/docs/asset-api-technical-reference}
*/
assetAPIEndpoint?: string
/**
* The Prismic Migration API endpoint.
*
* @defaultValue `"https://migration.prismic.io/"`
*
* @see Prismic Migration API technical reference: {@link https://prismic.io/docs/migration-api-technical-reference}
*/
migrationAPIEndpoint?: string
} & ClientConfig
/**
* A client that allows querying and writing content to a Prismic repository.
*
* If used in an environment where a global `fetch` function is unavailable,
* such as Node.js, the `fetch` option must be provided as part of the `options`
* parameter.
*
* @typeParam TDocuments - Document types that are registered for the Prismic
* repository. Query methods will automatically be typed based on this type.
*/
export class WriteClient<
TDocuments extends PrismicDocument = PrismicDocument,
> extends Client<TDocuments> {
writeToken: string
assetAPIEndpoint = "https://asset-api.prismic.io/"
migrationAPIEndpoint = "https://migration.prismic.io/"
/**
* Creates a Prismic client that can be used to query and write content to a
* repository.
*
* If used in an environment where a global `fetch` function is unavailable,
* such as in some Node.js versions, the `fetch` option must be provided as
* part of the `options` parameter.
*
* @param repositoryName - The Prismic repository name for the repository.
* @param options - Configuration that determines how content will be queried
* from and written to the Prismic repository.
*
* @returns A client that can query and write content to the repository.
*/
constructor(repositoryName: string, options: WriteClientConfig) {
super(repositoryName, options)
if (typeof globalThis.window !== "undefined") {
console.warn(
`[@prismicio/client] Prismic write client appears to be running in a browser environment. This is not recommended as it exposes your write token. Consider using Prismic write client in a server environment only, preferring the regular client for browser environment. For more details, see ${devMsg("avoid-write-client-in-browser")}`,
)
}
this.writeToken = options.writeToken
if (options.assetAPIEndpoint) {
this.assetAPIEndpoint = `${options.assetAPIEndpoint}/`
}
if (options.migrationAPIEndpoint) {
this.migrationAPIEndpoint = `${options.migrationAPIEndpoint}/`
}
}
/**
* Creates a migration release on the Prismic repository based on the provided
* prepared migration.
*
* @param migration - A migration prepared with {@link createMigration}.
* @param params - An event listener and additional fetch parameters.
*
* @see Prismic Migration API technical reference: {@link https://prismic.io/docs/migration-api-technical-reference}
*/
async migrate(
migration: Migration<TDocuments>,
params: {
reporter?: (event: MigrateReporterEvents) => void
} & FetchParams = {},
): Promise<void> {
params.reporter?.({
type: "start",
data: {
pending: {
documents: migration._documents.length,
assets: migration._assets.size,
},
},
})
await this.migrateCreateAssets(migration, params)
await this.migrateCreateDocuments(migration, params)
await this.migrateUpdateDocuments(migration, params)
params.reporter?.({
type: "end",
data: {
migrated: {
documents: migration._documents.length,
assets: migration._assets.size,
},
},
})
}
/**
* Creates assets in the Prismic repository's media library.
*
* @param migration - A migration prepared with {@link createMigration}.
* @param params - An event listener and additional fetch parameters.
*
* @internal This method is one of the step performed by the {@link migrate} method.
*/
private async migrateCreateAssets(
migration: Migration<TDocuments>,
{
reporter,
...fetchParams
}: { reporter?: (event: MigrateReporterEvents) => void } & FetchParams = {},
): Promise<void> {
let created = 0
for (const [_, migrationAsset] of migration._assets) {
reporter?.({
type: "assets:creating",
data: {
current: ++created,
remaining: migration._assets.size - created,
total: migration._assets.size,
asset: migrationAsset,
},
})
const { file, filename, notes, credits, alt, tags } =
migrationAsset.config
let resolvedFile: PostAssetParams["file"] | File
if (typeof file === "string") {
let url: URL | undefined
try {
url = new URL(file)
} catch (error) {
// noop only on invalid URL, fetch errors will throw in the next if statement
}
if (url) {
// File is a URL, fetch it
resolvedFile = await this.fetchForeignAsset(
url.toString(),
fetchParams,
)
} else {
// File is actual file content, use it as-is
resolvedFile = file
}
} else if (file instanceof URL) {
// File is a URL instance, fetch it
resolvedFile = await this.fetchForeignAsset(
file.toString(),
fetchParams,
)
} else {
resolvedFile = file
}
const asset = await this.createAsset(resolvedFile, filename, {
...{ notes, credits, alt, tags },
...fetchParams,
})
migrationAsset.asset = asset
}
reporter?.({
type: "assets:created",
data: {
created,
},
})
}
/**
* Creates documents in the Prismic repository's migration release.
*
* @param migration - A migration prepared with {@link createMigration}.
* @param params - An event listener and additional fetch parameters.
*
* @internal This method is one of the step performed by the {@link migrate} method.
*/
private async migrateCreateDocuments(
migration: Migration<TDocuments>,
{
reporter,
...fetchParams
}: { reporter?: (event: MigrateReporterEvents) => void } & FetchParams = {},
): Promise<void> {
// Resolve master locale
const repository = await this.getRepository(fetchParams)
const masterLocale = repository.languages.find((lang) => lang.is_master)!.id
reporter?.({
type: "documents:masterLocale",
data: {
masterLocale,
},
})
const documentsToCreate: PrismicMigrationDocument<TDocuments>[] = []
// We create an array with non-master locale documents last because
// we need their master locale document to be created first.
for (const doc of migration._documents) {
if (!doc.document.id) {
if (doc.document.lang === masterLocale) {
documentsToCreate.unshift(doc)
} else {
documentsToCreate.push(doc)
}
}
}
let created = 0
for (const doc of documentsToCreate) {
reporter?.({
type: "documents:creating",
data: {
current: ++created,
remaining: documentsToCreate.length - created,
total: documentsToCreate.length,
document: doc,
},
})
// Resolve master language document ID for non-master locale documents
let masterLanguageDocumentID: string | undefined
if (doc.masterLanguageDocument) {
const masterLanguageDocument =
await resolveMigrationContentRelationship(doc.masterLanguageDocument)
masterLanguageDocumentID =
"id" in masterLanguageDocument ? masterLanguageDocument.id : undefined
} else if (doc.originalPrismicDocument) {
const maybeOriginalID =
doc.originalPrismicDocument.alternate_languages.find(
({ lang }) => lang === masterLocale,
)?.id
if (maybeOriginalID) {
masterLanguageDocumentID =
migration._getByOriginalID(maybeOriginalID)?.document.id
}
}
const { id } = await this.createDocument(
// We'll upload documents data later on.
{ ...doc.document, data: {} },
doc.title!,
{
masterLanguageDocumentID,
...fetchParams,
},
)
doc.document.id = id
}
reporter?.({
type: "documents:created",
data: { created },
})
}
/**
* Updates documents in the Prismic repository's migration release with their
* patched data.
*
* @param migration - A migration prepared with {@link createMigration}.
* @param params - An event listener and additional fetch parameters.
*
* @internal This method is one of the step performed by the {@link migrate} method.
*/
private async migrateUpdateDocuments(
migration: Migration<TDocuments>,
{
reporter,
...fetchParams
}: { reporter?: (event: MigrateReporterEvents) => void } & FetchParams = {},
): Promise<void> {
let i = 0
for (const doc of migration._documents) {
reporter?.({
type: "documents:updating",
data: {
current: ++i,
remaining: migration._documents.length - i,
total: migration._documents.length,
document: doc,
},
})
await this.updateDocument(
doc.document.id!,
// We need to forward again document name and tags to update them
// in case the document already existed during the previous step.
{
...doc.document,
documentTitle: doc.title,
data: await resolveMigrationDocumentData(
doc.document.data,
migration,
),
},
fetchParams,
)
}
reporter?.({
type: "documents:updated",
data: {
updated: migration._documents.length,
},
})
}
/**
* Creates an asset in the Prismic media library.
*
* @param file - The file to upload as an asset.
* @param filename - The filename of the asset.
* @param params - Additional asset data and fetch parameters.
*
* @returns The created asset.
*/
private async createAsset(
file: PostAssetParams["file"] | File,
filename: string,
{
notes,
credits,
alt,
tags,
...params
}: CreateAssetParams & FetchParams = {},
): Promise<Asset> {
const url = new URL("assets", this.assetAPIEndpoint)
const formData = new FormData()
formData.append(
"file",
new File([file], filename, {
type: file instanceof File ? file.type : undefined,
}),
)
if (notes) {
formData.append("notes", notes)
}
if (credits) {
formData.append("credits", credits)
}
if (alt) {
formData.append("alt", alt)
}
const response = await this.#request(url, params, {
method: "POST",
body: formData,
})
switch (response.status) {
case 200: {
const asset = (await response.json()) as PostAssetResult
if (tags && tags.length) {
return this.updateAsset(asset.id, { tags })
}
return asset
}
default: {
return await this.#handleAssetAPIError(response)
}
}
}
/**
* Updates an asset in the Prismic media library.
*
* @param id - The ID of the asset to update.
* @param params - The asset data to update and additional fetch parameters.
*
* @returns The updated asset.
*/
private async updateAsset(
id: string,
{
notes,
credits,
alt,
filename,
tags,
...params
}: PatchAssetParams & FetchParams = {},
): Promise<Asset> {
const url = new URL(`assets/${id}`, this.assetAPIEndpoint)
// Resolve tags if any and create missing ones
if (tags && tags.length) {
tags = await this.resolveAssetTagIDs(tags, {
createTags: true,
...params,
})
}
const response = await this.#request(url, params, {
method: "PATCH",
body: JSON.stringify({
notes,
credits,
alt,
filename,
tags,
}),
headers: {
"content-type": "application/json",
},
})
switch (response.status) {
case 200: {
return (await response.json()) as Asset
}
default: {
return await this.#handleAssetAPIError(response)
}
}
}
/**
* Fetches a foreign asset from a URL.
*
* @param url - The URL of the asset to fetch.
* @param params - Additional fetch parameters.
*
* @returns A file representing the fetched asset.
*/
private async fetchForeignAsset(
url: string,
params: FetchParams = {},
): Promise<Blob> {
const res = await this.fetchFn(url, this._buildRequestInit(params))
if (!res.ok) {
throw new PrismicError("Could not fetch foreign asset", url, undefined)
}
const blob = await res.blob()
// Ensure a correct content type is attached to the blob.
return new File([blob], "", {
type: res.headers.get("content-type") || undefined,
})
}
/**
* {@link resolveAssetTagIDs} rate limiter.
*/
private _resolveAssetTagIDsLimit = pLimit()
/**
* Resolves asset tag IDs from tag names.
*
* @param tagNames - An array of tag names to resolve.
* @param params - Whether or not missing tags should be created and
* additional fetch parameters.
*
* @returns An array of resolved tag IDs.
*/
private async resolveAssetTagIDs(
tagNames: string[] = [],
{ createTags, ...params }: { createTags?: boolean } & FetchParams = {},
): Promise<string[]> {
return this._resolveAssetTagIDsLimit(async () => {
const existingTags = await this.getAssetTags(params)
const existingTagMap: Record<string, AssetTag> = {}
for (const tag of existingTags) {
existingTagMap[tag.name] = tag
}
const resolvedTagIDs = []
for (const tagName of tagNames) {
// Tag does not exists yet, we create it if `createTags` is set
if (!existingTagMap[tagName] && createTags) {
existingTagMap[tagName] = await this.createAssetTag(tagName, params)
}
// Add tag if found
if (existingTagMap[tagName]) {
resolvedTagIDs.push(existingTagMap[tagName].id)
}
}
return resolvedTagIDs
})
}
/**
* Creates a tag in the Asset API.
*
* @remarks
* Tags should be at least 3 characters long and 20 characters at most.
*
* @param name - The name of the tag to create.
* @param params - Additional fetch parameters.
*
* @returns The created tag.
*/
private async createAssetTag(
name: string,
params?: FetchParams,
): Promise<AssetTag> {
const url = new URL("tags", this.assetAPIEndpoint)
const response = await this.#request(url, params, {
method: "POST",
body: JSON.stringify({ name }),
headers: {
"content-type": "application/json",
},
})
switch (response.status) {
case 201: {
return (await response.json()) as PostAssetTagResult
}
default: {
return await this.#handleAssetAPIError(response)
}
}
}
/**
* Queries existing tags from the Asset API.
*
* @param params - Additional fetch parameters.
*
* @returns An array of existing tags.
*/
private async getAssetTags(params?: FetchParams): Promise<AssetTag[]> {
const url = new URL("tags", this.assetAPIEndpoint)
const response = await this.#request(url, params)
switch (response.status) {
case 200: {
const json = (await response.json()) as GetAssetTagsResult
return json.items
}
default: {
return await this.#handleAssetAPIError(response)
}
}
}
/**
* Creates a document in the repository's migration release.
*
* @typeParam TType - Type of Prismic documents to create.
*
* @param document - The document to create.
* @param documentTitle - The title of the document to create which will be
* displayed in the editor.
* @param params - Document master language document ID and additional fetch
* parameters.
*
* @returns The ID of the created document.
*
* @see Prismic Migration API technical reference: {@link https://prismic.io/docs/migration-api-technical-reference}
*/
private async createDocument<TType extends TDocuments["type"]>(
document: PendingPrismicDocument<ExtractDocumentType<TDocuments, TType>>,
documentTitle: string,
{
masterLanguageDocumentID,
...params
}: { masterLanguageDocumentID?: string } & FetchParams = {},
): Promise<{ id: string }> {
const url = new URL("documents", this.migrationAPIEndpoint)
const response = await this.#request(url, params, {
method: "POST",
body: JSON.stringify({
title: documentTitle,
type: document.type,
uid: document.uid || undefined,
lang: document.lang,
alternate_language_id: masterLanguageDocumentID,
tags: document.tags,
data: document.data,
}),
headers: {
"content-type": "application/json",
"x-client": CLIENT_IDENTIFIER,
},
})
switch (response.status) {
case 201: {
const json = (await response.json()) as PostDocumentResult
return { id: json.id }
}
default: {
return await this.#handleMigrationAPIError(response)
}
}
}
/**
* Updates an existing document in the repository's migration release.
*
* @typeParam TType - Type of Prismic documents to update.
*
* @param id - The ID of the document to update.
* @param document - The document content to update.
* @param params - Additional fetch parameters.
*
* @see Prismic Migration API technical reference: {@link https://prismic.io/docs/migration-api-technical-reference}
*/
private async updateDocument<TType extends TDocuments["type"]>(
id: string,
document: MigrationDocument<ExtractDocumentType<TDocuments, TType>> & {
documentTitle?: string
},
params?: FetchParams,
): Promise<void> {
const url = new URL(`documents/${id}`, this.migrationAPIEndpoint)
const response = await this.#request(url, params, {
method: "PUT",
body: JSON.stringify({
title: document.documentTitle,
uid: document.uid || undefined,
tags: document.tags,
data: document.data,
}),
headers: {
"content-type": "application/json",
"x-client": CLIENT_IDENTIFIER,
},
})
switch (response.status) {
case 200: {
return
}
default: {
await this.#handleMigrationAPIError(response)
}
}
}
/**
* Makes an authenticated HTTP request for write operations using the client's
* configured fetch function and options.
*
* @param url - The URL to request.
* @param params - Fetch options from the user.
* @param init - Additional fetch options to merge with the user-provided
* options.
*
* @returns The response from the fetch request.
*/
async #request(
url: URL,
params?: RequestInitLike,
init?: RequestInitLike,
): Promise<ResponseLike> {
const baseInit = this._buildRequestInit(params)
return await request(
url,
{
...baseInit,
...init,
headers: {
...baseInit.headers,
...init?.headers,
repository: this.repositoryName,
authorization: `Bearer ${this.writeToken}`,
},
},
this.fetchFn,
)
}
/**
* Handles error responses from the Asset API with comprehensive error
* parsing.
*
* @param response - The HTTP response from the Asset API.
*
* @throws {@link InvalidDataError} For 400 errors.
* @throws {@link ForbiddenError} For 401 and 403 errors.
* @throws {@link NotFoundError} For 404 errors.
* @throws {@link PrismicError} For 500, 503, and other unexpected errors.
*/
async #handleAssetAPIError(response: ResponseLike): Promise<never> {
const json = await response.json()
switch (response.status) {
case 401:
case 403:
throw new ForbiddenError(json.error, response.url, json)
case 404:
throw new NotFoundError(json.error, response.url, json)
case 400:
throw new InvalidDataError(json.error, response.url, json)
case 500:
case 503:
default:
throw new PrismicError(json.error, response.url, json)
}
}
/**
* Handles error responses from the Migration API with comprehensive error
* parsing.
*
* @param response - The HTTP response from the Migration API.
*
* @throws {@link InvalidDataError} For 400 errors.
* @throws {@link ForbiddenError} For 401 and 403 errors.
* @throws {@link NotFoundError} For 404 errors.
* @throws {@link PrismicError} For 500, and other unexpected errors.
*/
async #handleMigrationAPIError(response: ResponseLike): Promise<never> {
// Some responses come with a JSON body, some with a text body.
const text = await response.text()
let json: unknown
try {
json = JSON.parse(text)
} catch {
// no-op
}
switch (response.status) {
case 400:
if (json) {
throw new InvalidDataError(
"Validation failed, check the response property of the error for details",
response.url,
json, // `json` has the shape Array<{ property: string, value: unknown, error: string }>
)
}
throw new InvalidDataError(text, response.url, text)
case 401:
throw new ForbiddenError(text, response.url, text)
case 403:
if (json) {
throw new ForbiddenError(
(json as { Message: string }).Message,
response.url,
json,
)
}
throw new ForbiddenError(text, response.url, text)
case 404:
throw new NotFoundError(text, response.url, text)
case 500:
default:
throw new PrismicError(text, response.url, text)
}
}
}