next-drupal
Version:
Helpers for Next.js + Drupal.
1 lines • 149 kB
Source Map (JSON)
{"version":3,"sources":["../src/index.ts","../src/draft-constants.ts","../src/jsonapi-errors.ts","../src/next-drupal-base.ts","../src/logger.ts","../src/next-drupal.ts","../src/menu-tree.ts","../src/next-drupal-pages.ts","../src/deprecated/get-cache.ts","../src/deprecated/get-access-token.ts","../src/deprecated/utils.ts","../src/deprecated/get-menu.ts","../src/deprecated/get-resource-collection.ts","../src/deprecated/get-paths.ts","../src/deprecated/get-resource.ts","../src/deprecated/preview.ts","../src/deprecated/translate-path.ts","../src/deprecated/get-resource-type.ts","../src/deprecated/get-search-index.ts","../src/deprecated/get-view.ts","../src/deprecated.ts"],"sourcesContent":["export * from \"./draft-constants\"\nexport * from \"./jsonapi-errors\"\nexport * from \"./next-drupal-base\"\nexport * from \"./next-drupal\"\nexport * from \"./next-drupal-pages\"\n\nexport type * from \"./types\"\n\nexport * from \"./deprecated\"\n","export const DRAFT_DATA_COOKIE_NAME = \"next_drupal_draft_data\"\n\n// See https://vercel.com/docs/workflow-collaboration/draft-mode\nexport const DRAFT_MODE_COOKIE_NAME = \"__prerender_bypass\"\n","// https://jsonapi.org/format/#error-objects\nexport interface JsonApiError {\n id?: string\n status?: string\n code?: string\n title?: string\n detail?: string\n links?: JsonApiLinks\n}\n\n// https://jsonapi.org/format/#document-links\nexport interface JsonApiLinks {\n [key: string]: string | Record<string, string>\n}\n\n/** @hidden */\nexport class JsonApiErrors extends Error {\n errors: JsonApiError[] | string\n statusCode: number\n\n constructor(\n errors: JsonApiError[] | string,\n statusCode: number,\n messagePrefix: string = \"\"\n ) {\n super()\n\n this.errors = errors\n this.statusCode = statusCode\n this.message =\n (messagePrefix ? `${messagePrefix} ` : \"\") +\n JsonApiErrors.formatMessage(errors)\n }\n\n static formatMessage(errors: JsonApiError[] | string) {\n if (typeof errors === \"string\") {\n return errors\n }\n\n const [error] = errors\n\n let message = `${error.status} ${error.title}`\n\n if (error.detail) {\n message += `\\n${error.detail}`\n }\n\n return message\n }\n}\n","import { stringify } from \"qs\"\nimport { JsonApiErrors } from \"./jsonapi-errors\"\nimport { logger as defaultLogger } from \"./logger\"\nimport type {\n AccessToken,\n BaseUrl,\n EndpointSearchParams,\n FetchOptions,\n JsonApiResponse,\n Locale,\n Logger,\n NextDrupalAuth,\n NextDrupalAuthAccessToken,\n NextDrupalAuthClientIdSecret,\n NextDrupalAuthUsernamePassword,\n NextDrupalBaseOptions,\n PathPrefix,\n} from \"./types\"\n\nconst DEFAULT_API_PREFIX = \"\"\nconst DEFAULT_FRONT_PAGE = \"/home\"\nconst DEFAULT_WITH_AUTH = false\n\n// From simple_oauth.\nconst DEFAULT_AUTH_URL = \"/oauth/token\"\n\n// See https://jsonapi.org/format/#content-negotiation.\nconst DEFAULT_HEADERS = {\n \"Content-Type\": \"application/json\",\n Accept: \"application/json\",\n}\n\n/**\n * The base class for NextDrupal clients.\n */\nexport class NextDrupalBase {\n accessToken?: NextDrupalBaseOptions[\"accessToken\"]\n\n baseUrl: BaseUrl\n\n fetcher?: NextDrupalBaseOptions[\"fetcher\"]\n\n frontPage: string\n\n isDebugEnabled: boolean\n\n logger: Logger\n\n withAuth: boolean\n\n private _apiPrefix: string\n\n private _auth?: NextDrupalAuth\n\n private _headers: Headers\n\n private _token?: AccessToken\n\n private _tokenExpiresOn?: number\n\n private _tokenRequestDetails?: NextDrupalAuthClientIdSecret\n\n /**\n * Instantiates a new NextDrupalBase.\n *\n * const client = new NextDrupalBase(baseUrl)\n *\n * @param {baseUrl} baseUrl The baseUrl of your Drupal site. Do not add the /jsonapi suffix.\n * @param {options} options Options for NextDrupalBase.\n */\n constructor(baseUrl: BaseUrl, options: NextDrupalBaseOptions = {}) {\n if (!baseUrl || typeof baseUrl !== \"string\") {\n throw new Error(\"The 'baseUrl' param is required.\")\n }\n\n const {\n accessToken,\n apiPrefix = DEFAULT_API_PREFIX,\n auth,\n debug = false,\n fetcher,\n frontPage = DEFAULT_FRONT_PAGE,\n headers = DEFAULT_HEADERS,\n logger = defaultLogger,\n withAuth = DEFAULT_WITH_AUTH,\n } = options\n\n this.accessToken = accessToken\n this.apiPrefix = apiPrefix\n this.auth = auth\n this.baseUrl = baseUrl\n this.fetcher = fetcher\n this.frontPage = frontPage\n this.isDebugEnabled = !!debug\n this.headers = headers\n this.logger = logger\n this.withAuth = withAuth\n\n this.debug(\"Debug mode is on.\")\n }\n\n set apiPrefix(apiPrefix: string) {\n this._apiPrefix =\n apiPrefix === \"\" || apiPrefix.startsWith(\"/\")\n ? apiPrefix\n : `/${apiPrefix}`\n }\n\n get apiPrefix() {\n return this._apiPrefix\n }\n\n set auth(auth: NextDrupalAuth) {\n if (typeof auth === \"object\") {\n const checkUsernamePassword = auth as NextDrupalAuthUsernamePassword\n const checkAccessToken = auth as NextDrupalAuthAccessToken\n const checkClientIdSecret = auth as NextDrupalAuthClientIdSecret\n\n if (\n checkUsernamePassword.username !== undefined ||\n checkUsernamePassword.password !== undefined\n ) {\n if (\n !checkUsernamePassword.username ||\n !checkUsernamePassword.password\n ) {\n throw new Error(\n \"'username' and 'password' are required for auth. See https://next-drupal.org/docs/client/auth\"\n )\n }\n } else if (\n checkAccessToken.access_token !== undefined ||\n checkAccessToken.token_type !== undefined\n ) {\n if (!checkAccessToken.access_token || !checkAccessToken.token_type) {\n throw new Error(\n \"'access_token' and 'token_type' are required for auth. See https://next-drupal.org/docs/client/auth\"\n )\n }\n } else if (\n !checkClientIdSecret.clientId ||\n !checkClientIdSecret.clientSecret\n ) {\n throw new Error(\n \"'clientId' and 'clientSecret' are required for auth. See https://next-drupal.org/docs/client/auth\"\n )\n }\n\n this._auth = {\n ...(isClientIdSecretAuth(auth) ? { url: DEFAULT_AUTH_URL } : {}),\n ...auth,\n }\n } else {\n this._auth = auth\n }\n }\n\n get auth() {\n return this._auth\n }\n\n set headers(headers: HeadersInit) {\n this._headers = new Headers(headers)\n }\n\n get headers() {\n return this._headers\n }\n\n set token(token: AccessToken) {\n this._token = token\n this._tokenExpiresOn = Date.now() + token.expires_in * 1000\n }\n\n get token() {\n return this._token\n }\n\n /**\n * Fetches a resource from the given input URL or path.\n *\n * @param {RequestInfo} input The url to fetch from.\n * @param {FetchOptions} init The fetch options with `withAuth`.\n * If `withAuth` is set, `fetch` will fetch an `Authorization` header before making the request.\n * @returns {Promise<Response>} The fetch response.\n * @remarks\n * To provide your own custom fetcher, see the fetcher docs.\n * @example\n * ```ts\n * const url = drupal.buildUrl(\"/jsonapi/node/article\", {\n * sort: \"-created\",\n * \"fields[node--article]\": \"title,path\",\n * })\n *\n * const response = await drupal.fetch(url.toString())\n * ```\n */\n async fetch(\n input: RequestInfo,\n { withAuth, ...init }: FetchOptions = {}\n ): Promise<Response> {\n init.credentials = \"include\"\n\n // Merge the init.headers with this.headers\n const headers = new Headers(this.headers)\n if (init?.headers) {\n const initHeaders = new Headers(init?.headers)\n for (const key of initHeaders.keys()) {\n headers.set(key, initHeaders.get(key))\n }\n }\n\n // Set Authorization header.\n if (withAuth) {\n headers.set(\n \"Authorization\",\n await this.getAuthorizationHeader(\n withAuth === true ? this.auth : withAuth\n )\n )\n }\n\n init.headers = headers\n\n if (typeof input === \"string\" && input.startsWith(\"/\")) {\n input = `${this.baseUrl}${input}`\n }\n\n if (this.fetcher) {\n this.debug(`Using custom fetcher, fetching: ${input}`)\n\n return await this.fetcher(input, init)\n }\n\n this.debug(`Using default fetch, fetching: ${input}`)\n\n return await fetch(input, init)\n }\n\n /**\n * Gets the authorization header value based on the provided auth configuration.\n *\n * @param {NextDrupalAuth} auth The auth configuration.\n * @returns {Promise<string>} The authorization header value.\n */\n async getAuthorizationHeader(auth: NextDrupalAuth) {\n let header: string\n\n if (isBasicAuth(auth)) {\n const basic = Buffer.from(`${auth.username}:${auth.password}`).toString(\n \"base64\"\n )\n header = `Basic ${basic}`\n this.debug(\"Using basic authorization header.\")\n } else if (isClientIdSecretAuth(auth)) {\n // Fetch an access token and add it to the request. getAccessToken()\n // throws an error if it fails to get an access token.\n const token = await this.getAccessToken(auth)\n header = `Bearer ${token.access_token}`\n this.debug(\n \"Using access token authorization header retrieved from Client Id/Secret.\"\n )\n } else if (isAccessTokenAuth(auth)) {\n header = `${auth.token_type} ${auth.access_token}`\n this.debug(\"Using access token authorization header.\")\n } else if (typeof auth === \"string\") {\n header = auth\n this.debug(\"Using custom authorization header.\")\n } else if (typeof auth === \"function\") {\n header = auth()\n this.debug(\"Using custom authorization callback.\")\n } else {\n throw new Error(\n \"auth is not configured. See https://next-drupal.org/docs/client/auth\"\n )\n }\n\n return header\n }\n\n /**\n * Builds a URL with the given path and search parameters.\n *\n * @param {string} path The path for the url. Example: \"/example\"\n * @param {string | Record<string, string> | URLSearchParams | JsonApiParams} searchParams Optional query parameters.\n * @returns {URL} The constructed URL.\n * @example\n * ```ts\n * const drupal = new DrupalClient(\"https://example.com\")\n *\n * // https://drupal.org\n * drupal.buildUrl(\"https://drupal.org\").toString()\n *\n * // https://example.com/foo\n * drupal.buildUrl(\"/foo\").toString()\n *\n * // https://example.com/foo?bar=baz\n * client.buildUrl(\"/foo\", { bar: \"baz\" }).toString()\n * ```\n *\n * Build a URL from `DrupalJsonApiParams`\n * ```ts\n * const params = {\n * getQueryObject: () => ({\n * sort: \"-created\",\n * \"fields[node--article]\": \"title,path\",\n * }),\n * }\n *\n * // https://example.com/jsonapi/node/article?sort=-created&fields%5Bnode--article%5D=title%2Cpath\n * drupal.buildUrl(\"/jsonapi/node/article\", params).toString()\n * ```\n */\n buildUrl(path: string, searchParams?: EndpointSearchParams): URL {\n const url = new URL(path, this.baseUrl)\n\n const search =\n // Handle DrupalJsonApiParams objects.\n searchParams &&\n typeof searchParams === \"object\" &&\n \"getQueryObject\" in searchParams\n ? searchParams.getQueryObject()\n : searchParams\n\n if (search) {\n // Use stringify instead of URLSearchParams for nested params.\n url.search = stringify(search)\n }\n\n return url\n }\n\n /**\n * Builds an endpoint URL with the given options.\n *\n * @param {Object} options The options for building the endpoint.\n * @param {string} options.locale The locale.\n * @param {string} options.path The path.\n * @param {EndpointSearchParams} options.searchParams The search parameters.\n * @returns {Promise<string>} The constructed endpoint URL.\n */\n async buildEndpoint({\n locale = \"\",\n path = \"\",\n searchParams,\n }: {\n locale?: string\n path?: string\n searchParams?: EndpointSearchParams\n } = {}): Promise<string> {\n const localeSegment = locale ? `/${locale}` : \"\"\n\n if (path && !path.startsWith(\"/\")) {\n path = `/${path}`\n }\n\n return this.buildUrl(\n `${localeSegment}${this.apiPrefix}${path}`,\n searchParams\n ).toString()\n }\n\n /**\n * Constructs a path from the given segment and options.\n *\n * @param {string | string[]} segment The path segment.\n * @param {Object} options The options for constructing the path.\n * @param {Locale} options.locale The locale.\n * @param {Locale} options.defaultLocale The default locale.\n * @param {PathPrefix} options.pathPrefix The path prefix.\n * @returns {string} The constructed path.\n */\n constructPathFromSegment(\n segment: string | string[],\n options: {\n locale?: Locale\n defaultLocale?: Locale\n pathPrefix?: PathPrefix\n } = {}\n ) {\n let { pathPrefix = \"\" } = options\n const { locale, defaultLocale } = options\n\n // Ensure pathPrefix starts with a \"/\" and does not end with a \"/\".\n if (pathPrefix) {\n if (!pathPrefix?.startsWith(\"/\")) {\n pathPrefix = `/${options.pathPrefix}`\n }\n if (pathPrefix.endsWith(\"/\")) {\n pathPrefix = pathPrefix.slice(0, -1)\n }\n }\n\n // If the segment is given as an array of segments, join the parts.\n if (!Array.isArray(segment)) {\n segment = segment ? [segment] : []\n }\n segment = segment.map((part) => encodeURIComponent(part)).join(\"/\")\n\n if (!segment && !pathPrefix) {\n // If no pathPrefix is given and the segment is empty, then the path\n // should be the homepage.\n segment = this.frontPage\n }\n\n // Ensure the segment starts with a \"/\" and does not end with a \"/\".\n if (segment && !segment.startsWith(\"/\")) {\n segment = `/${segment}`\n }\n if (segment.endsWith(\"/\")) {\n segment = segment.slice(0, -1)\n }\n\n return this.addLocalePrefix(`${pathPrefix}${segment}`, {\n locale,\n defaultLocale,\n })\n }\n\n /**\n * Adds a locale prefix to the given path.\n *\n * @param {string} path The path.\n * @param {Object} options The options for adding the locale prefix.\n * @param {Locale} options.locale The locale.\n * @param {Locale} options.defaultLocale The default locale.\n * @returns {string} The path with the locale prefix.\n */\n addLocalePrefix(\n path: string,\n options: { locale?: Locale; defaultLocale?: Locale } = {}\n ) {\n const { locale, defaultLocale } = options\n\n if (!path.startsWith(\"/\")) {\n path = `/${path}`\n }\n\n let localePrefix = \"\"\n if (locale && !path.startsWith(`/${locale}`) && locale !== defaultLocale) {\n localePrefix = `/${locale}`\n }\n\n return `${localePrefix}${path}`\n }\n\n /**\n * Retrieve an access token.\n *\n * @param {NextDrupalAuthClientIdSecret} clientIdSecret The client ID and secret.\n * @returns {Promise<AccessToken>} The access token.\n * @remarks\n * If options is not provided, `DrupalClient` will use the `clientId` and `clientSecret` configured in `auth`.\n * @example\n * ```ts\n * const accessToken = await drupal.getAccessToken({\n * clientId: \"7034f4db-7151-466f-a711-8384bddb9e60\",\n * clientSecret: \"d92Fm^ds\",\n * })\n * ```\n */\n async getAccessToken(\n clientIdSecret?: NextDrupalAuthClientIdSecret\n ): Promise<AccessToken> {\n if (this.accessToken) {\n return this.accessToken\n }\n\n let auth: NextDrupalAuthClientIdSecret\n if (isClientIdSecretAuth(clientIdSecret)) {\n auth = {\n url: DEFAULT_AUTH_URL,\n ...clientIdSecret,\n }\n } else if (isClientIdSecretAuth(this.auth)) {\n auth = { ...this.auth }\n } else if (typeof this.auth === \"undefined\") {\n throw new Error(\n \"auth is not configured. See https://next-drupal.org/docs/client/auth\"\n )\n } else {\n throw new Error(\n `'clientId' and 'clientSecret' required. See https://next-drupal.org/docs/client/auth`\n )\n }\n\n const url = this.buildUrl(auth.url)\n\n // Ensure that the unexpired token was using the same scope and client\n // credentials as the current request before re-using it.\n if (\n this.token &&\n Date.now() < this._tokenExpiresOn &&\n this._tokenRequestDetails?.clientId === auth?.clientId &&\n this._tokenRequestDetails?.clientSecret === auth?.clientSecret &&\n this._tokenRequestDetails?.scope === auth?.scope\n ) {\n this.debug(`Using existing access token.`)\n return this.token\n }\n\n this.debug(`Fetching new access token.`)\n\n // Use BasicAuth to retrieve the access token.\n const clientCredentials: NextDrupalAuthUsernamePassword = {\n username: auth.clientId,\n password: auth.clientSecret,\n }\n const body = new URLSearchParams({ grant_type: \"client_credentials\" })\n\n if (auth?.scope) {\n body.set(\"scope\", auth.scope)\n\n this.debug(`Using scope: ${auth.scope}`)\n }\n\n const response = await this.fetch(url.toString(), {\n method: \"POST\",\n headers: {\n Authorization: await this.getAuthorizationHeader(clientCredentials),\n Accept: \"application/json\",\n \"Content-Type\": \"application/x-www-form-urlencoded\",\n },\n body,\n })\n\n await this.throwIfJsonErrors(\n response,\n \"Error while fetching new access token: \"\n )\n\n const result: AccessToken = await response.json()\n\n this.token = result\n\n this._tokenRequestDetails = auth\n\n return result\n }\n\n /**\n * Validates the draft URL using the provided search parameters.\n *\n * @param {URLSearchParams} searchParams The search parameters.\n * @returns {Promise<Response>} The validation response.\n */\n async validateDraftUrl(searchParams: URLSearchParams): Promise<Response> {\n const path = searchParams.get(\"path\")\n\n this.debug(`Fetching draft url validation for ${path}.`)\n\n // Fetch the headless CMS to check if the provided `path` exists\n let response: Response\n try {\n // Validate the draft url.\n const validateUrl = this.buildUrl(\"/next/draft-url\").toString()\n response = await this.fetch(validateUrl, {\n method: \"POST\",\n headers: {\n Accept: \"application/vnd.api+json\",\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify(Object.fromEntries(searchParams.entries())),\n })\n } catch (error) {\n response = new Response(JSON.stringify({ message: error.message }), {\n status: 401,\n })\n }\n\n this.debug(\n response.status !== 200\n ? `Could not validate path, ${path}`\n : `Validated path, ${path}`\n )\n\n return response\n }\n\n /**\n * Logs a debug message if debug mode is enabled.\n *\n * @param {string} message The debug message.\n */\n debug(message) {\n this.isDebugEnabled && this.logger.debug(message)\n }\n\n /**\n * Throws an error if the response contains JSON:API errors.\n *\n * @param {Response} response The fetch response.\n * @param {string} messagePrefix The error message prefix.\n * @throws {JsonApiErrors} The JSON:API errors.\n */\n async throwIfJsonErrors(response: Response, messagePrefix = \"\") {\n if (!response?.ok) {\n const errors = await this.getErrorsFromResponse(response)\n throw new JsonApiErrors(errors, response.status, messagePrefix)\n }\n }\n\n /**\n * Extracts errors from the fetch response.\n *\n * @param {Response} response The fetch response.\n * @returns {Promise<string | JsonApiResponse>} The extracted errors.\n */\n async getErrorsFromResponse(response: Response) {\n const type = response.headers.get(\"content-type\")\n let error: JsonApiResponse | { message: string }\n\n if (type === \"application/json\") {\n error = await response.json()\n\n if (error?.message) {\n return error.message as string\n }\n }\n\n // Construct error from response.\n // Check for type to ensure this is a JSON:API formatted error.\n // See https://jsonapi.org/format/#errors.\n else if (type === \"application/vnd.api+json\") {\n error = (await response.json()) as JsonApiResponse\n\n if (error?.errors?.length) {\n return error.errors\n }\n }\n\n return response.statusText\n }\n}\n\n/**\n * Checks if the provided auth configuration is basic auth.\n *\n * @param {NextDrupalAuth} auth The auth configuration.\n * @returns {boolean} True if the auth configuration is basic auth, false otherwise.\n */\nexport function isBasicAuth(\n auth: NextDrupalAuth\n): auth is NextDrupalAuthUsernamePassword {\n return (\n (auth as NextDrupalAuthUsernamePassword)?.username !== undefined &&\n (auth as NextDrupalAuthUsernamePassword)?.password !== undefined\n )\n}\n\n/**\n * Checks if the provided auth configuration is access token auth.\n *\n * @param {NextDrupalAuth} auth The auth configuration.\n * @returns {boolean} True if the auth configuration is access token auth, false otherwise.\n */\nexport function isAccessTokenAuth(\n auth: NextDrupalAuth\n): auth is NextDrupalAuthAccessToken {\n return (\n (auth as NextDrupalAuthAccessToken)?.access_token !== undefined &&\n (auth as NextDrupalAuthAccessToken)?.token_type !== undefined\n )\n}\n\n/**\n * Checks if the provided auth configuration is client ID and secret auth.\n *\n * @param {NextDrupalAuth} auth The auth configuration.\n * @returns {boolean} True if the auth configuration is client ID and secret auth, false otherwise.\n */\nexport function isClientIdSecretAuth(\n auth: NextDrupalAuth\n): auth is NextDrupalAuthClientIdSecret {\n return (\n (auth as NextDrupalAuthClientIdSecret)?.clientId !== undefined &&\n (auth as NextDrupalAuthClientIdSecret)?.clientSecret !== undefined\n )\n}\n","import type { Logger } from \"./types\"\n\nexport const LOG_MESSAGE_PREFIX = \"[next-drupal][log]:\"\nexport const DEBUG_MESSAGE_PREFIX = \"[next-drupal][debug]:\"\nexport const WARN_MESSAGE_PREFIX = \"[next-drupal][warn]:\"\nexport const ERROR_MESSAGE_PREFIX = \"[next-drupal][error]:\"\n\n// Default logger. Uses console.\nexport const logger: Logger = {\n log(message) {\n console.log(LOG_MESSAGE_PREFIX, message)\n },\n debug(message) {\n console.debug(DEBUG_MESSAGE_PREFIX, message)\n },\n warn(message) {\n console.warn(WARN_MESSAGE_PREFIX, message)\n },\n error(message) {\n console.error(ERROR_MESSAGE_PREFIX, message)\n },\n}\n","import { Jsona } from \"jsona\"\nimport { stringify } from \"qs\"\nimport { JsonApiErrors } from \"./jsonapi-errors\"\nimport { DrupalMenuTree } from \"./menu-tree\"\nimport { NextDrupalBase } from \"./next-drupal-base\"\nimport type {\n BaseUrl,\n DrupalFile,\n DrupalMenuItem,\n DrupalTranslatedPath,\n DrupalView,\n JsonApiCreateFileResourceBody,\n JsonApiCreateResourceBody,\n JsonApiOptions,\n JsonApiParams,\n JsonApiResource,\n JsonApiResourceWithPath,\n JsonApiResponse,\n JsonApiUpdateResourceBody,\n JsonApiWithAuthOption,\n JsonApiWithCacheOptions,\n JsonApiWithNextFetchOptions,\n JsonDeserializer,\n Locale,\n NextDrupalOptions,\n PathPrefix,\n} from \"./types\"\n\nconst DEFAULT_API_PREFIX = \"/jsonapi\"\n\n// See https://jsonapi.org/format/#content-negotiation.\nconst DEFAULT_HEADERS = {\n \"Content-Type\": \"application/vnd.api+json\",\n Accept: \"application/vnd.api+json\",\n}\n\nexport function useJsonaDeserialize() {\n const jsonFormatter = new Jsona()\n return function jsonaDeserialize(\n body: Parameters<JsonDeserializer>[0],\n options: Parameters<JsonDeserializer>[1]\n ) {\n return jsonFormatter.deserialize(body, options)\n }\n}\n\n/**\n * The NextDrupal class extends the NextDrupalBase class and provides methods\n * for interacting with a Drupal backend.\n */\nexport class NextDrupal extends NextDrupalBase {\n cache?: NextDrupalOptions[\"cache\"]\n\n deserializer: JsonDeserializer\n\n throwJsonApiErrors: boolean\n\n useDefaultEndpoints: boolean\n\n /**\n * Instantiates a new NextDrupal.\n *\n * const client = new NextDrupal(baseUrl)\n *\n * @param {baseUrl} baseUrl The baseUrl of your Drupal site. Do not add the /jsonapi suffix.\n * @param {options} options Options for NextDrupal.\n */\n constructor(baseUrl: BaseUrl, options: NextDrupalOptions = {}) {\n super(baseUrl, options)\n\n const {\n apiPrefix = DEFAULT_API_PREFIX,\n cache = null,\n deserializer,\n headers = DEFAULT_HEADERS,\n throwJsonApiErrors = true,\n useDefaultEndpoints = true,\n } = options\n\n this.apiPrefix = apiPrefix\n this.cache = cache\n this.deserializer = deserializer ?? useJsonaDeserialize()\n this.headers = headers\n this.throwJsonApiErrors = !!throwJsonApiErrors\n this.useDefaultEndpoints = !!useDefaultEndpoints\n\n // Do not throw errors in production.\n if (process.env.NODE_ENV === \"production\") {\n this.throwJsonApiErrors = false\n }\n }\n\n /**\n * Creates a new resource of the specified type.\n *\n * @param {string} type The type of the resource. Example: `node--article`, `taxonomy_term--tags`, or `block_content--basic`.\n * @param {JsonApiCreateResourceBody} body The body payload with data.\n * @param {JsonApiOptions & JsonApiWithNextFetchOptions} options Options for the request.\n * @returns {Promise<T>} The created resource.\n * @example\n * Create a node--page resource\n * ```\n * const page = await drupal.createResource(\"node--page\", {\n * data: {\n * attributes: {\n * title: \"Page Title\",\n * body: {\n * value: \"<p>Content of body field</p>\",\n * format: \"full_html\",\n * },\n * },\n * },\n * })\n * ```\n * Create a node--article with a taxonomy term\n * ```\n * const article = await drupal.createResource(\"node--article\", {\n * data: {\n * attributes: {\n * title: \"Title of Article\",\n * body: {\n * value: \"<p>Content of body field</p>\",\n * format: \"full_html\",\n * },\n * },\n * relationships: {\n * field_category: {\n * data: {\n * type: \"taxonomy_term--category\",\n * id: \"28ab9f26-927d-4e33-9510-b59a7ccdafe6\",\n * },\n * },\n * },\n * },\n * })\n * ```\n * Using filters\n * ```\n * const page = await drupal.createResource(\n * \"node--page\",\n * {\n * data: {\n * attributes: {\n * title: \"Page Title\",\n * body: {\n * value: \"<p>Content of body field</p>\",\n * format: \"full_html\",\n * },\n * },\n * },\n * },\n * {\n * params: {\n * \"fields[node--page]\": \"title,path\",\n * },\n * }\n * )\n * ```\n * Using TypeScript with DrupalNode\n * ```\n * import { DrupalNode } from \"next-drupal\"\n * const page = await drupal.createResource<DrupalNode>(\"node--page\", {\n * data: {\n * attributes: {\n * title: \"Page Title\",\n * body: {\n * value: \"<p>Content of body field</p>\",\n * format: \"full_html\",\n * },\n * },\n * },\n * })\n * ```\n */\n async createResource<T extends JsonApiResource>(\n type: string,\n body: JsonApiCreateResourceBody,\n options?: JsonApiOptions & JsonApiWithNextFetchOptions\n ): Promise<T> {\n options = {\n deserialize: true,\n withAuth: true,\n ...options,\n }\n\n const endpoint = await this.buildEndpoint({\n locale:\n options?.locale !== options?.defaultLocale\n ? /* c8 ignore next */ options.locale\n : undefined,\n resourceType: type,\n searchParams: options?.params,\n })\n\n this.debug(`Creating resource of type ${type}.`)\n\n // Add type to body.\n body.data.type = type\n\n const response = await this.fetch(endpoint, {\n method: \"POST\",\n body: JSON.stringify(body),\n withAuth: options.withAuth,\n cache: options.cache,\n })\n\n await this.throwIfJsonErrors(response, \"Error while creating resource: \")\n\n const json = await response.json()\n\n return options.deserialize\n ? this.deserialize(json)\n : /* c8 ignore next */ json\n }\n\n /**\n * Creates a new file resource for the specified media type.\n *\n * @param {string} type The type of the resource. In most cases this is `file--file`.\n * @param {JsonApiCreateFileResourceBody} body The body payload with data.\n * - type: The resource type of the host entity. Example: `media--image`\n * - field: The name of the file field on the host entity. Example: `field_media_image`\n * - filename: The name of the file with extension. Example: `avatar.jpg`\n * - file: The file as a Buffer\n * @param {JsonApiOptions} options Options for the request.\n * @returns {Promise<T>} The created file resource.\n * @example\n * Create a file resource for a media--image entity\n * ```ts\n * const file = await drupal.createFileResource(\"file--file\", {\n * data: {\n * attributes: {\n * type: \"media--image\", // The type of the parent resource\n * field: \"field_media_image\", // The name of the field on the parent resource\n * filename: \"filename.jpg\",\n * file: await fs.readFile(\"/path/to/file.jpg\"),\n * },\n * },\n * })\n * ```\n *\n * You can then use this to create a new media--image with a relationship to the file:\n * ```ts\n * const media = await drupal.createResource<DrupalMedia>(\"media--image\", {\n * data: {\n * attributes: {\n * name: \"Name for the media\",\n * },\n * relationships: {\n * field_media_image: {\n * data: {\n * type: \"file--file\",\n * id: file.id,\n * },\n * },\n * },\n * },\n * })\n * ```\n */\n async createFileResource<T = DrupalFile>(\n type: string,\n body: JsonApiCreateFileResourceBody,\n options?: JsonApiOptions & JsonApiWithNextFetchOptions\n ): Promise<T> {\n options = {\n deserialize: true,\n withAuth: true,\n ...options,\n }\n\n const resourceType = body?.data?.attributes?.type\n\n const endpoint = await this.buildEndpoint({\n locale:\n options?.locale !== options?.defaultLocale ? options.locale : undefined,\n resourceType,\n path: `/${body.data.attributes.field}`,\n searchParams: options?.params,\n })\n\n this.debug(`Creating file resource for media of type ${type}.`)\n\n const response = await this.fetch(endpoint, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/octet-stream\",\n Accept: \"application/vnd.api+json\",\n \"Content-Disposition\": `file; filename=\"${body.data.attributes.filename}\"`,\n },\n body: body.data.attributes.file,\n withAuth: options.withAuth,\n cache: options.cache,\n })\n\n await this.throwIfJsonErrors(\n response,\n \"Error while creating file resource: \"\n )\n\n const json = await response.json()\n\n return options.deserialize ? this.deserialize(json) : json\n }\n\n /**\n * Updates an existing resource of the specified type.\n *\n * @param {string} type The type of the resource. Example: `node--article`, `taxonomy_term--tags`, or `block_content--basic`.\n * @param {string} uuid The resource id. Example: `a50ffee7-ba94-46c9-9705-f9f8f440db94`.\n * @param {JsonApiUpdateResourceBody} body The body payload with data.\n * @param {JsonApiOptions & JsonApiWithNextFetchOptions} options Options for the request.\n * @returns {Promise<T>} The updated resource.\n * @example\n * Update a node--page resource\n * ```ts\n * const page = await drupal.updateResource(\n * \"node--page\",\n * \"a50ffee7-ba94-46c9-9705-f9f8f440db94\",\n * {\n * data: {\n * attributes: {\n * title: \"Updated Title\",\n * },\n * },\n * }\n * )\n * ```\n *\n * Using TypeScript with DrupalNode for a node entity type\n * ```ts\n * import { DrupalNode } from \"next-drupal\"\n *\n * const page = await drupal.updateResource<DrupalNode>(\n * \"node--page\",\n * \"a50ffee7-ba94-46c9-9705-f9f8f440db94\",\n * {\n * data: {\n * attributes: {\n * title: \"Updated Title\",\n * },\n * },\n * }\n * )\n * ```\n */\n async updateResource<T extends JsonApiResource>(\n type: string,\n uuid: string,\n body: JsonApiUpdateResourceBody,\n options?: JsonApiOptions & JsonApiWithNextFetchOptions\n ): Promise<T> {\n options = {\n deserialize: true,\n withAuth: true,\n ...options,\n }\n\n const endpoint = await this.buildEndpoint({\n locale:\n options?.locale !== options?.defaultLocale\n ? /* c8 ignore next */ options.locale\n : undefined,\n resourceType: type,\n path: `/${uuid}`,\n searchParams: options?.params,\n })\n\n this.debug(`Updating resource of type ${type} with id ${uuid}.`)\n\n // Update body.\n body.data.type = type\n body.data.id = uuid\n\n const response = await this.fetch(endpoint, {\n method: \"PATCH\",\n body: JSON.stringify(body),\n withAuth: options.withAuth,\n cache: options.cache,\n })\n\n await this.throwIfJsonErrors(response, \"Error while updating resource: \")\n\n const json = await response.json()\n\n return options.deserialize\n ? this.deserialize(json)\n : /* c8 ignore next */ json\n }\n\n /**\n * Deletes an existing resource of the specified type.\n *\n * @param {string} type The type of the resource. Example: `node--article`, `taxonomy_term--tags`, or `block_content--basic`.\n * @param {string} uuid The resource id. Example: `a50ffee7-ba94-46c9-9705-f9f8f440db94`.\n * @param {JsonApiOptions & JsonApiWithNextFetchOptions} options Options for the request.\n * @returns {Promise<boolean>} True if the resource was deleted, false otherwise.\n * @example\n * Delete a node--page resource\n * ```ts\n * const isDeleted = await drupal.deleteResource(\n * \"node--page\",\n * \"a50ffee7-ba94-46c9-9705-f9f8f440db94\"\n * )\n * ```\n */\n async deleteResource(\n type: string,\n uuid: string,\n options?: JsonApiOptions & JsonApiWithNextFetchOptions\n ): Promise<boolean> {\n options = {\n withAuth: true,\n params: {},\n ...options,\n }\n\n const endpoint = await this.buildEndpoint({\n locale:\n options?.locale !== options?.defaultLocale\n ? /* c8 ignore next */ options.locale\n : undefined,\n resourceType: type,\n path: `/${uuid}`,\n searchParams: options?.params,\n })\n\n this.debug(`Deleting resource of type ${type} with id ${uuid}.`)\n\n const response = await this.fetch(endpoint, {\n method: \"DELETE\",\n withAuth: options.withAuth,\n cache: options.cache,\n })\n\n await this.throwIfJsonErrors(response, \"Error while deleting resource: \")\n\n return response.status === 204\n }\n\n /**\n * Fetches a resource of the specified type by its UUID.\n *\n * @param {string} type The resource type. Example: `node--article`, `taxonomy_term--tags`, or `block_content--basic`.\n * @param {string} uuid The id of the resource. Example: `15486935-24bf-4be7-b858-a5b2de78d09d`.\n * @param {JsonApiOptions & JsonApiWithCacheOptions & JsonApiWithNextFetchOptions} options Options for the request.\n * @returns {Promise<T>} The fetched resource.\n * @examples\n * Get a page by uuid.\n * ```ts\n * const node = await drupal.getResource(\n * \"node--page\",\n * \"07464e9f-9221-4a4f-b7f2-01389408e6c8\"\n * )\n * ```\n * Get the es translation for a page by uuid.\n * ```ts\n * const node = await drupal.getResource(\n * \"node--page\",\n * \"07464e9f-9221-4a4f-b7f2-01389408e6c8\",\n * {\n * locale: \"es\",\n * defaultLocale: \"en\",\n * }\n * )\n * ```\n * Get the raw JSON:API response.\n * ```ts\n * const { data, meta, links } = await drupal.getResource(\n * \"node--page\",\n * \"07464e9f-9221-4a4f-b7f2-01389408e6c8\",\n * {\n * deserialize: false,\n * }\n * )\n * ```\n * Get a node--article resource using cache.\n * ```ts\n * const id = \"07464e9f-9221-4a4f-b7f2-01389408e6c8\"\n *\n * const article = await drupal.getResource(\"node--article\", id, {\n * withCache: true,\n * cacheKey: `node--article:${id}`,\n * })\n * ```\n * Get a page resource with time-based revalidation.\n * ```ts\n * const node = await drupal.getResource(\n * \"node--page\",\n * \"07464e9f-9221-4a4f-b7f2-01389408e6c8\",\n * { next: { revalidate: 3600 } }\n * )\n * ```\n * Get a page resource with tag-based revalidation.\n * ```ts\n * const {slug} = params;\n * const path = drupal.translatePath(slug)\n *\n * const type = path.jsonapi.resourceName\n * const tag = `${path.entity.type}:${path.entity.id}`\n *\n * const node = await drupal.getResource(path, path.entity.uuid, {\n * params: params.getQueryObject(),\n * tags: [tag]\n * })\n * ```\n * Using DrupalNode for a node entity type.\n * ```ts\n * import { DrupalNode } from \"next-drupal\"\n *\n * const node = await drupal.getResource<DrupalNode>(\n * \"node--page\",\n * \"07464e9f-9221-4a4f-b7f2-01389408e6c8\"\n * )\n * ```\n * Using DrupalTaxonomyTerm for a taxonomy term entity type.\n * ```ts\n * import { DrupalTaxonomyTerm } from \"next-drupal\"\n *\n * const term = await drupal.getResource<DrupalTaxonomyTerm>(\n * \"taxonomy_term--tags\",\n * \"7b47d7cc-9b1b-4867-a909-75dc1d61dfd3\"\n * )\n * ```\n */\n async getResource<T extends JsonApiResource>(\n type: string,\n uuid: string,\n options?: JsonApiOptions &\n JsonApiWithCacheOptions &\n JsonApiWithNextFetchOptions\n ): Promise<T> {\n options = {\n deserialize: true,\n withAuth: this.withAuth,\n withCache: false,\n params: {},\n ...options,\n }\n\n /* c8 ignore next 11 */\n if (options.withCache) {\n const cached = (await this.cache.get(options.cacheKey)) as string\n\n if (cached) {\n this.debug(`Returning cached resource ${type} with id ${uuid}.`)\n\n const json = JSON.parse(cached)\n\n return options.deserialize ? this.deserialize(json) : json\n }\n }\n\n const endpoint = await this.buildEndpoint({\n locale:\n options?.locale !== options?.defaultLocale ? options.locale : undefined,\n resourceType: type,\n path: `/${uuid}`,\n searchParams: options?.params,\n })\n\n this.debug(`Fetching resource ${type} with id ${uuid}.`)\n\n const response = await this.fetch(endpoint, {\n withAuth: options.withAuth,\n next: options.next,\n cache: options.cache,\n })\n\n await this.throwIfJsonErrors(response, \"Error while fetching resource: \")\n\n const json = await response.json()\n\n /* c8 ignore next 3 */\n if (options.withCache) {\n await this.cache.set(options.cacheKey, JSON.stringify(json))\n }\n\n return options.deserialize ? this.deserialize(json) : json\n }\n\n /**\n * Fetches a resource of the specified type by its path.\n *\n * @param {string} path The path of the resource. Example: `/blog/slug-for-article`.\n * @param { { isVersionable?: boolean } & JsonApiOptions & JsonApiWithNextFetchOptions} options Options for the request.\n * - isVersionable: Set to true if you're fetching the revision for a resource. Automatically set to true for node entity types\n * @returns {Promise<T>} The fetched resource.\n * @requires Decoupled Router module\n * @example\n * Get a page by path\n * ```\n * const node = await drupal.getResourceByPath(\"/blog/slug-for-article\")\n * ```\n * Get the raw JSON:API response\n * ```\n * const { data, meta, links } = await drupal.getResourceByPath(\n * \"/blog/slug-for-article\",\n * {\n * deserialize: false,\n * }\n * )\n *```\n * Using DrupalNode for a node entity type\n * ```\n * import { DrupalNode } from \"next-drupal\"\n * const node = await drupal.getResourceByPath<DrupalNode>(\n * \"/blog/slug-for-article\"\n * )\n * ```\n */\n async getResourceByPath<T extends JsonApiResource>(\n path: string,\n options?: {\n isVersionable?: boolean\n } & JsonApiOptions &\n JsonApiWithNextFetchOptions\n ): Promise<T> {\n options = {\n deserialize: true,\n isVersionable: false,\n withAuth: this.withAuth,\n params: {},\n ...options,\n }\n\n if (!path) {\n return null\n }\n\n path = this.addLocalePrefix(path, {\n locale: options.locale,\n defaultLocale: options.defaultLocale,\n })\n\n // If a resourceVersion is provided, assume entity type is versionable.\n if (options.params.resourceVersion) {\n options.isVersionable = true\n }\n\n const { resourceVersion = \"rel:latest-version\", ...params } = options.params\n\n if (options.isVersionable) {\n params.resourceVersion = resourceVersion\n }\n\n const resourceParams = stringify(params)\n\n // We are intentionally not using translatePath here.\n // We want a single request using subrequests.\n const payload = [\n {\n requestId: \"router\",\n action: \"view\",\n uri: `/router/translate-path?path=${path}&_format=json`,\n headers: { Accept: \"application/vnd.api+json\" },\n },\n {\n requestId: \"resolvedResource\",\n action: \"view\",\n uri: `{{router.body@$.jsonapi.individual}}?${resourceParams.toString()}`,\n waitFor: [\"router\"],\n },\n ]\n\n // Handle localized subrequests. It seems like subrequests is not properly\n // setting the jsonapi locale from a translated path.\n // TODO: Confirm we still need this after https://www.drupal.org/i/3111456\n const subrequestsEndpoint = this.addLocalePrefix(\"/subrequests\", {\n locale: options.locale,\n defaultLocale: options.defaultLocale,\n })\n\n const endpoint = this.buildUrl(subrequestsEndpoint, {\n _format: \"json\",\n }).toString()\n\n this.debug(`Fetching resource by path, ${path}.`)\n\n const response = await this.fetch(endpoint, {\n method: \"POST\",\n credentials: \"include\",\n redirect: \"follow\",\n body: JSON.stringify(payload),\n withAuth: options.withAuth,\n next: options.next,\n cache: options.cache,\n })\n\n const errorMessagePrefix = \"Error while fetching resource by path:\"\n\n if (response.status !== 207) {\n const errors = await this.getErrorsFromResponse(response)\n throw new JsonApiErrors(errors, response.status, errorMessagePrefix)\n }\n\n const json = await response.json()\n\n if (!json?.[\"resolvedResource#uri{0}\"]?.body) {\n const status = json?.router?.headers?.status?.[0]\n if (status === 404) {\n return null\n }\n const message =\n (json?.router?.body && JSON.parse(json.router.body)?.message) ||\n \"Unknown error\"\n throw new JsonApiErrors(message, status, errorMessagePrefix)\n }\n\n const data = JSON.parse(json[\"resolvedResource#uri{0}\"]?.body)\n\n if (data.errors) {\n const status = json?.[\"resolvedResource#uri{0}\"]?.headers?.status?.[0]\n this.logOrThrowError(\n new JsonApiErrors(data.errors, status, errorMessagePrefix)\n )\n }\n\n return options.deserialize ? this.deserialize(data) : data\n }\n\n /**\n * Fetches a collection of resources of the specified type.\n *\n * @param {string} type The type of the resources. Example: `node--article` or `user--user`.\n * @param {JsonApiOptions & JsonApiWithNextFetchOptions} options Options for the request.\n * - deserialize: Set to false to return the raw JSON:API response\n * @returns {Promise<T>} The fetched collection of resources.\n * @example\n * Get all articles\n * ```\n * const articles = await drupal.getResourceCollection(\"node--article\")\n * ```\n * Using filters\n * ```\n * const publishedArticles = await drupal.getResourceCollection(\"node--article\", {\n * params: {\n * \"filter[status]\": \"1\",\n * },\n * })\n * ```\n * Get the raw JSON:API response\n * ```\n * const { data, meta, links } = await drupal.getResourceCollection(\"node--page\", {\n * deserialize: false,\n * })\n * ```\n * Using TypeScript with DrupalNode for a node entity type\n * ```\n * import { DrupalNode } from \"next-drupal\"\n * const nodes = await drupal.getResourceCollection<DrupalNode[]>(\"node--article\")\n * ```\n */\n async getResourceCollection<T = JsonApiResource[]>(\n type: string,\n options?: {\n deserialize?: boolean\n } & JsonApiOptions &\n JsonApiWithNextFetchOptions\n ): Promise<T> {\n options = {\n withAuth: this.withAuth,\n deserialize: true,\n ...options,\n }\n\n const endpoint = await this.buildEndpoint({\n locale:\n options?.locale !== options?.defaultLocale ? options.locale : undefined,\n resourceType: type,\n searchParams: options?.params,\n })\n\n this.debug(`Fetching resource collection of type ${type}.`)\n\n const response = await this.fetch(endpoint, {\n withAuth: options.withAuth,\n next: options.next,\n cache: options.cache,\n })\n\n await this.throwIfJsonErrors(\n response,\n \"Error while fetching resource collection: \"\n )\n\n const json = await response.json()\n\n return options.deserialize ? this.deserialize(json) : json\n }\n\n /**\n * Fetches path segments for a collection of resources of the specified types.\n *\n * @param {string | string[]} types The types of the resources.\n * @param {JsonApiOptions & JsonApiWithAuthOption & JsonApiWithNextFetchOptions} options Options for the request.\n * @returns {Promise<{ path: string, type: string, locale: Locale, segments: string[] }[]>} The fetched path segments.\n */\n async getResourceCollectionPathSegments(\n types: string | string[],\n options?: {\n pathPrefix?: PathPrefix\n params?: JsonApiParams\n } & JsonApiWithAuthOption &\n JsonApiWithNextFetchOptions &\n (\n | {\n locales: Locale[]\n defaultLocale: Locale\n }\n | {\n locales?: undefined\n defaultLocale?: never\n }\n )\n ): Promise<\n {\n path: string\n type: string\n locale: Locale\n segments: string[]\n }[]\n > {\n options = {\n withAuth: this.withAuth,\n pathPrefix: \"\",\n params: {},\n ...options,\n }\n\n if (typeof types === \"string\") {\n types = [types]\n }\n\n const paths = await Promise.all(\n types.map(async (type) => {\n // Use sparse fieldset to expand max size.\n // Note we don't need status filter here since this runs non-authenticated (by default).\n const params = {\n [`fields[${type}]`]: \"path\",\n ...options?.params,\n }\n\n const locales = options?.locales?.length ? options.locales : [undefined]\n\n return Promise.all(\n locales.map(async (locale) => {\n let opts: Parameters<NextDrupal[\"getResourceCollection\"]>[1] = {\n params,\n withAuth: options.withAuth,\n next: options.next,\n cache: options.cache,\n }\n if (locale) {\n opts = {\n ...opts,\n deserialize: true,\n locale,\n defaultLocale: options.defaultLocale,\n }\n }\n const resources = await this.getResourceCollection<\n JsonApiResourceWithPath[]\n >(type, opts)\n\n return (\n resources\n .map((resource) => {\n return resource?.path?.alias === this.frontPage\n ? /* c8 ignore next */ \"/\"\n : resource?.path?.alias\n })\n // Remove results with no path aliases.\n .filter(Boolean)\n .map((path) => {\n let segmentPath = path\n\n // Trim the pathPrefix off the front of the path.\n if (\n options.pathPrefix &&\n (segmentPath.startsWith(\n `${options.pathPrefix}/`\n ) /* c8 ignore next */ ||\n segmentPath === options.pathPrefix)\n ) {\n segmentPath = segmentPath.slice(options.pathPrefix.length)\n }\n\n // Convert the trimmed path into an array of path segments.\n const segments = segmentPath.split(\"/\").filter(Boolean)\n\n return {\n path,\n type,\n locale,\n segments,\n }\n })\n )\n