next-drupal
Version:
Helpers for Next.js + Drupal.
1,538 lines (1,529 loc) • 84.8 kB
JavaScript
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// src/index.ts
var src_exports = {};
__export(src_exports, {
DRAFT_DATA_COOKIE_NAME: () => DRAFT_DATA_COOKIE_NAME,
DRAFT_MODE_COOKIE_NAME: () => DRAFT_MODE_COOKIE_NAME,
DrupalClient: () => DrupalClient,
DrupalPreview: () => DrupalPreview,
JsonApiErrors: () => JsonApiErrors,
NextDrupal: () => NextDrupal,
NextDrupalBase: () => NextDrupalBase,
NextDrupalPages: () => NextDrupalPages,
PreviewHandler: () => PreviewHandler,
buildUrl: () => buildUrl,
deserialize: () => deserialize,
getAccessToken: () => getAccessToken,
getJsonApiIndex: () => getJsonApiIndex,
getJsonApiPathForResourceType: () => getJsonApiPathForResourceType,
getMenu: () => getMenu,
getPathsFromContext: () => getPathsFromContext,
getResource: () => getResource,
getResourceByPath: () => getResourceByPath,
getResourceCollection: () => getResourceCollection,
getResourceCollectionFromContext: () => getResourceCollectionFromContext,
getResourceFromContext: () => getResourceFromContext,
getResourcePreviewUrl: () => getResourcePreviewUrl,
getResourceTypeFromContext: () => getResourceTypeFromContext,
getSearchIndex: () => getSearchIndex,
getSearchIndexFromContext: () => getSearchIndexFromContext,
getView: () => getView,
isAccessTokenAuth: () => isAccessTokenAuth,
isBasicAuth: () => isBasicAuth,
isClientIdSecretAuth: () => isClientIdSecretAuth,
syncDrupalPreviewRoutes: () => syncDrupalPreviewRoutes,
translatePath: () => translatePath,
translatePathFromContext: () => translatePathFromContext,
useJsonaDeserialize: () => useJsonaDeserialize
});
module.exports = __toCommonJS(src_exports);
// src/draft-constants.ts
var DRAFT_DATA_COOKIE_NAME = "next_drupal_draft_data";
var DRAFT_MODE_COOKIE_NAME = "__prerender_bypass";
// src/jsonapi-errors.ts
var JsonApiErrors = class _JsonApiErrors extends Error {
constructor(errors, statusCode, messagePrefix = "") {
super();
this.errors = errors;
this.statusCode = statusCode;
this.message = (messagePrefix ? `${messagePrefix} ` : "") + _JsonApiErrors.formatMessage(errors);
}
static formatMessage(errors) {
if (typeof errors === "string") {
return errors;
}
const [error] = errors;
let message = `${error.status} ${error.title}`;
if (error.detail) {
message += `
${error.detail}`;
}
return message;
}
};
// src/next-drupal-base.ts
var import_qs = require("qs");
// src/logger.ts
var LOG_MESSAGE_PREFIX = "[next-drupal][log]:";
var DEBUG_MESSAGE_PREFIX = "[next-drupal][debug]:";
var WARN_MESSAGE_PREFIX = "[next-drupal][warn]:";
var ERROR_MESSAGE_PREFIX = "[next-drupal][error]:";
var logger = {
log(message) {
console.log(LOG_MESSAGE_PREFIX, message);
},
debug(message) {
console.debug(DEBUG_MESSAGE_PREFIX, message);
},
warn(message) {
console.warn(WARN_MESSAGE_PREFIX, message);
},
error(message) {
console.error(ERROR_MESSAGE_PREFIX, message);
}
};
// src/next-drupal-base.ts
var DEFAULT_API_PREFIX = "";
var DEFAULT_FRONT_PAGE = "/home";
var DEFAULT_WITH_AUTH = false;
var DEFAULT_AUTH_URL = "/oauth/token";
var DEFAULT_HEADERS = {
"Content-Type": "application/json",
Accept: "application/json"
};
var NextDrupalBase = class {
/**
* Instantiates a new NextDrupalBase.
*
* const client = new NextDrupalBase(baseUrl)
*
* @param {baseUrl} baseUrl The baseUrl of your Drupal site. Do not add the /jsonapi suffix.
* @param {options} options Options for NextDrupalBase.
*/
constructor(baseUrl, options = {}) {
if (!baseUrl || typeof baseUrl !== "string") {
throw new Error("The 'baseUrl' param is required.");
}
const {
accessToken,
apiPrefix = DEFAULT_API_PREFIX,
auth,
debug = false,
fetcher,
frontPage = DEFAULT_FRONT_PAGE,
headers = DEFAULT_HEADERS,
logger: logger2 = logger,
withAuth = DEFAULT_WITH_AUTH
} = options;
this.accessToken = accessToken;
this.apiPrefix = apiPrefix;
this.auth = auth;
this.baseUrl = baseUrl;
this.fetcher = fetcher;
this.frontPage = frontPage;
this.isDebugEnabled = !!debug;
this.headers = headers;
this.logger = logger2;
this.withAuth = withAuth;
this.debug("Debug mode is on.");
}
set apiPrefix(apiPrefix) {
this._apiPrefix = apiPrefix === "" || apiPrefix.startsWith("/") ? apiPrefix : `/${apiPrefix}`;
}
get apiPrefix() {
return this._apiPrefix;
}
set auth(auth) {
if (typeof auth === "object") {
const checkUsernamePassword = auth;
const checkAccessToken = auth;
const checkClientIdSecret = auth;
if (checkUsernamePassword.username !== void 0 || checkUsernamePassword.password !== void 0) {
if (!checkUsernamePassword.username || !checkUsernamePassword.password) {
throw new Error(
"'username' and 'password' are required for auth. See https://next-drupal.org/docs/client/auth"
);
}
} else if (checkAccessToken.access_token !== void 0 || checkAccessToken.token_type !== void 0) {
if (!checkAccessToken.access_token || !checkAccessToken.token_type) {
throw new Error(
"'access_token' and 'token_type' are required for auth. See https://next-drupal.org/docs/client/auth"
);
}
} else if (!checkClientIdSecret.clientId || !checkClientIdSecret.clientSecret) {
throw new Error(
"'clientId' and 'clientSecret' are required for auth. See https://next-drupal.org/docs/client/auth"
);
}
this._auth = {
...isClientIdSecretAuth(auth) ? { url: DEFAULT_AUTH_URL } : {},
...auth
};
} else {
this._auth = auth;
}
}
get auth() {
return this._auth;
}
set headers(headers) {
this._headers = new Headers(headers);
}
get headers() {
return this._headers;
}
set token(token) {
this._token = token;
this._tokenExpiresOn = Date.now() + token.expires_in * 1e3;
}
get token() {
return this._token;
}
/**
* Fetches a resource from the given input URL or path.
*
* @param {RequestInfo} input The url to fetch from.
* @param {FetchOptions} init The fetch options with `withAuth`.
* If `withAuth` is set, `fetch` will fetch an `Authorization` header before making the request.
* @returns {Promise<Response>} The fetch response.
* @remarks
* To provide your own custom fetcher, see the fetcher docs.
* @example
* ```ts
* const url = drupal.buildUrl("/jsonapi/node/article", {
* sort: "-created",
* "fields[node--article]": "title,path",
* })
*
* const response = await drupal.fetch(url.toString())
* ```
*/
async fetch(input, { withAuth, ...init } = {}) {
init.credentials = "include";
const headers = new Headers(this.headers);
if (init?.headers) {
const initHeaders = new Headers(init?.headers);
for (const key of initHeaders.keys()) {
headers.set(key, initHeaders.get(key));
}
}
if (withAuth) {
headers.set(
"Authorization",
await this.getAuthorizationHeader(
withAuth === true ? this.auth : withAuth
)
);
}
init.headers = headers;
if (typeof input === "string" && input.startsWith("/")) {
input = `${this.baseUrl}${input}`;
}
if (this.fetcher) {
this.debug(`Using custom fetcher, fetching: ${input}`);
return await this.fetcher(input, init);
}
this.debug(`Using default fetch, fetching: ${input}`);
return await fetch(input, init);
}
/**
* Gets the authorization header value based on the provided auth configuration.
*
* @param {NextDrupalAuth} auth The auth configuration.
* @returns {Promise<string>} The authorization header value.
*/
async getAuthorizationHeader(auth) {
let header;
if (isBasicAuth(auth)) {
const basic = Buffer.from(`${auth.username}:${auth.password}`).toString(
"base64"
);
header = `Basic ${basic}`;
this.debug("Using basic authorization header.");
} else if (isClientIdSecretAuth(auth)) {
const token = await this.getAccessToken(auth);
header = `Bearer ${token.access_token}`;
this.debug(
"Using access token authorization header retrieved from Client Id/Secret."
);
} else if (isAccessTokenAuth(auth)) {
header = `${auth.token_type} ${auth.access_token}`;
this.debug("Using access token authorization header.");
} else if (typeof auth === "string") {
header = auth;
this.debug("Using custom authorization header.");
} else if (typeof auth === "function") {
header = auth();
this.debug("Using custom authorization callback.");
} else {
throw new Error(
"auth is not configured. See https://next-drupal.org/docs/client/auth"
);
}
return header;
}
/**
* Builds a URL with the given path and search parameters.
*
* @param {string} path The path for the url. Example: "/example"
* @param {string | Record<string, string> | URLSearchParams | JsonApiParams} searchParams Optional query parameters.
* @returns {URL} The constructed URL.
* @example
* ```ts
* const drupal = new DrupalClient("https://example.com")
*
* // https://drupal.org
* drupal.buildUrl("https://drupal.org").toString()
*
* // https://example.com/foo
* drupal.buildUrl("/foo").toString()
*
* // https://example.com/foo?bar=baz
* client.buildUrl("/foo", { bar: "baz" }).toString()
* ```
*
* Build a URL from `DrupalJsonApiParams`
* ```ts
* const params = {
* getQueryObject: () => ({
* sort: "-created",
* "fields[node--article]": "title,path",
* }),
* }
*
* // https://example.com/jsonapi/node/article?sort=-created&fields%5Bnode--article%5D=title%2Cpath
* drupal.buildUrl("/jsonapi/node/article", params).toString()
* ```
*/
buildUrl(path, searchParams) {
const url = new URL(path, this.baseUrl);
const search = (
// Handle DrupalJsonApiParams objects.
searchParams && typeof searchParams === "object" && "getQueryObject" in searchParams ? searchParams.getQueryObject() : searchParams
);
if (search) {
url.search = (0, import_qs.stringify)(search);
}
return url;
}
/**
* Builds an endpoint URL with the given options.
*
* @param {Object} options The options for building the endpoint.
* @param {string} options.locale The locale.
* @param {string} options.path The path.
* @param {EndpointSearchParams} options.searchParams The search parameters.
* @returns {Promise<string>} The constructed endpoint URL.
*/
async buildEndpoint({
locale = "",
path = "",
searchParams
} = {}) {
const localeSegment = locale ? `/${locale}` : "";
if (path && !path.startsWith("/")) {
path = `/${path}`;
}
return this.buildUrl(
`${localeSegment}${this.apiPrefix}${path}`,
searchParams
).toString();
}
/**
* Constructs a path from the given segment and options.
*
* @param {string | string[]} segment The path segment.
* @param {Object} options The options for constructing the path.
* @param {Locale} options.locale The locale.
* @param {Locale} options.defaultLocale The default locale.
* @param {PathPrefix} options.pathPrefix The path prefix.
* @returns {string} The constructed path.
*/
constructPathFromSegment(segment, options = {}) {
let { pathPrefix = "" } = options;
const { locale, defaultLocale } = options;
if (pathPrefix) {
if (!pathPrefix?.startsWith("/")) {
pathPrefix = `/${options.pathPrefix}`;
}
if (pathPrefix.endsWith("/")) {
pathPrefix = pathPrefix.slice(0, -1);
}
}
if (!Array.isArray(segment)) {
segment = segment ? [segment] : [];
}
segment = segment.map((part) => encodeURIComponent(part)).join("/");
if (!segment && !pathPrefix) {
segment = this.frontPage;
}
if (segment && !segment.startsWith("/")) {
segment = `/${segment}`;
}
if (segment.endsWith("/")) {
segment = segment.slice(0, -1);
}
return this.addLocalePrefix(`${pathPrefix}${segment}`, {
locale,
defaultLocale
});
}
/**
* Adds a locale prefix to the given path.
*
* @param {string} path The path.
* @param {Object} options The options for adding the locale prefix.
* @param {Locale} options.locale The locale.
* @param {Locale} options.defaultLocale The default locale.
* @returns {string} The path with the locale prefix.
*/
addLocalePrefix(path, options = {}) {
const { locale, defaultLocale } = options;
if (!path.startsWith("/")) {
path = `/${path}`;
}
let localePrefix = "";
if (locale && !path.startsWith(`/${locale}`) && locale !== defaultLocale) {
localePrefix = `/${locale}`;
}
return `${localePrefix}${path}`;
}
/**
* Retrieve an access token.
*
* @param {NextDrupalAuthClientIdSecret} clientIdSecret The client ID and secret.
* @returns {Promise<AccessToken>} The access token.
* @remarks
* If options is not provided, `DrupalClient` will use the `clientId` and `clientSecret` configured in `auth`.
* @example
* ```ts
* const accessToken = await drupal.getAccessToken({
* clientId: "7034f4db-7151-466f-a711-8384bddb9e60",
* clientSecret: "d92Fm^ds",
* })
* ```
*/
async getAccessToken(clientIdSecret) {
if (this.accessToken) {
return this.accessToken;
}
let auth;
if (isClientIdSecretAuth(clientIdSecret)) {
auth = {
url: DEFAULT_AUTH_URL,
...clientIdSecret
};
} else if (isClientIdSecretAuth(this.auth)) {
auth = { ...this.auth };
} else if (typeof this.auth === "undefined") {
throw new Error(
"auth is not configured. See https://next-drupal.org/docs/client/auth"
);
} else {
throw new Error(
`'clientId' and 'clientSecret' required. See https://next-drupal.org/docs/client/auth`
);
}
const url = this.buildUrl(auth.url);
if (this.token && Date.now() < this._tokenExpiresOn && this._tokenRequestDetails?.clientId === auth?.clientId && this._tokenRequestDetails?.clientSecret === auth?.clientSecret && this._tokenRequestDetails?.scope === auth?.scope) {
this.debug(`Using existing access token.`);
return this.token;
}
this.debug(`Fetching new access token.`);
const clientCredentials = {
username: auth.clientId,
password: auth.clientSecret
};
const body = new URLSearchParams({ grant_type: "client_credentials" });
if (auth?.scope) {
body.set("scope", auth.scope);
this.debug(`Using scope: ${auth.scope}`);
}
const response = await this.fetch(url.toString(), {
method: "POST",
headers: {
Authorization: await this.getAuthorizationHeader(clientCredentials),
Accept: "application/json",
"Content-Type": "application/x-www-form-urlencoded"
},
body
});
await this.throwIfJsonErrors(
response,
"Error while fetching new access token: "
);
const result = await response.json();
this.token = result;
this._tokenRequestDetails = auth;
return result;
}
/**
* Validates the draft URL using the provided search parameters.
*
* @param {URLSearchParams} searchParams The search parameters.
* @returns {Promise<Response>} The validation response.
*/
async validateDraftUrl(searchParams) {
const path = searchParams.get("path");
this.debug(`Fetching draft url validation for ${path}.`);
let response;
try {
const validateUrl = this.buildUrl("/next/draft-url").toString();
response = await this.fetch(validateUrl, {
method: "POST",
headers: {
Accept: "application/vnd.api+json",
"Content-Type": "application/json"
},
body: JSON.stringify(Object.fromEntries(searchParams.entries()))
});
} catch (error) {
response = new Response(JSON.stringify({ message: error.message }), {
status: 401
});
}
this.debug(
response.status !== 200 ? `Could not validate path, ${path}` : `Validated path, ${path}`
);
return response;
}
/**
* Logs a debug message if debug mode is enabled.
*
* @param {string} message The debug message.
*/
debug(message) {
this.isDebugEnabled && this.logger.debug(message);
}
/**
* Throws an error if the response contains JSON:API errors.
*
* @param {Response} response The fetch response.
* @param {string} messagePrefix The error message prefix.
* @throws {JsonApiErrors} The JSON:API errors.
*/
async throwIfJsonErrors(response, messagePrefix = "") {
if (!response?.ok) {
const errors = await this.getErrorsFromResponse(response);
throw new JsonApiErrors(errors, response.status, messagePrefix);
}
}
/**
* Extracts errors from the fetch response.
*
* @param {Response} response The fetch response.
* @returns {Promise<string | JsonApiResponse>} The extracted errors.
*/
async getErrorsFromResponse(response) {
const type = response.headers.get("content-type");
let error;
if (type === "application/json") {
error = await response.json();
if (error?.message) {
return error.message;
}
} else if (type === "application/vnd.api+json") {
error = await response.json();
if (error?.errors?.length) {
return error.errors;
}
}
return response.statusText;
}
};
function isBasicAuth(auth) {
return auth?.username !== void 0 && auth?.password !== void 0;
}
function isAccessTokenAuth(auth) {
return auth?.access_token !== void 0 && auth?.token_type !== void 0;
}
function isClientIdSecretAuth(auth) {
return auth?.clientId !== void 0 && auth?.clientSecret !== void 0;
}
// src/next-drupal.ts
var import_jsona = require("jsona");
var import_qs2 = require("qs");
// src/menu-tree.ts
var DrupalMenuTree = class _DrupalMenuTree extends Array {
constructor(menuItems, parentId = "", depth = 1) {
super();
this.parentId = parentId;
this.depth = depth;
if (menuItems?.length) {
this.build(menuItems, parentId);
}
}
build(menuItems, parentId) {
const children = menuItems.filter(
(menuItem) => menuItem?.parent === parentId
);
for (const menuItem of children) {
const subtree = new _DrupalMenuTree(
menuItems,
menuItem.id,
this.depth + 1
);
this.push({
...menuItem,
items: subtree.length ? subtree : void 0
});
}
}
};
// src/next-drupal.ts
var DEFAULT_API_PREFIX2 = "/jsonapi";
var DEFAULT_HEADERS2 = {
"Content-Type": "application/vnd.api+json",
Accept: "application/vnd.api+json"
};
function useJsonaDeserialize() {
const jsonFormatter = new import_jsona.Jsona();
return function jsonaDeserialize(body, options) {
return jsonFormatter.deserialize(body, options);
};
}
var NextDrupal = class extends NextDrupalBase {
/**
* Instantiates a new NextDrupal.
*
* const client = new NextDrupal(baseUrl)
*
* @param {baseUrl} baseUrl The baseUrl of your Drupal site. Do not add the /jsonapi suffix.
* @param {options} options Options for NextDrupal.
*/
constructor(baseUrl, options = {}) {
super(baseUrl, options);
const {
apiPrefix = DEFAULT_API_PREFIX2,
cache: cache2 = null,
deserializer,
headers = DEFAULT_HEADERS2,
throwJsonApiErrors = true,
useDefaultEndpoints = true
} = options;
this.apiPrefix = apiPrefix;
this.cache = cache2;
this.deserializer = deserializer ?? useJsonaDeserialize();
this.headers = headers;
this.throwJsonApiErrors = !!throwJsonApiErrors;
this.useDefaultEndpoints = !!useDefaultEndpoints;
if (process.env.NODE_ENV === "production") {
this.throwJsonApiErrors = false;
}
}
/**
* Creates a new resource of the specified type.
*
* @param {string} type The type of the resource. Example: `node--article`, `taxonomy_term--tags`, or `block_content--basic`.
* @param {JsonApiCreateResourceBody} body The body payload with data.
* @param {JsonApiOptions & JsonApiWithNextFetchOptions} options Options for the request.
* @returns {Promise<T>} The created resource.
* @example
* Create a node--page resource
* ```
* const page = await drupal.createResource("node--page", {
* data: {
* attributes: {
* title: "Page Title",
* body: {
* value: "<p>Content of body field</p>",
* format: "full_html",
* },
* },
* },
* })
* ```
* Create a node--article with a taxonomy term
* ```
* const article = await drupal.createResource("node--article", {
* data: {
* attributes: {
* title: "Title of Article",
* body: {
* value: "<p>Content of body field</p>",
* format: "full_html",
* },
* },
* relationships: {
* field_category: {
* data: {
* type: "taxonomy_term--category",
* id: "28ab9f26-927d-4e33-9510-b59a7ccdafe6",
* },
* },
* },
* },
* })
* ```
* Using filters
* ```
* const page = await drupal.createResource(
* "node--page",
* {
* data: {
* attributes: {
* title: "Page Title",
* body: {
* value: "<p>Content of body field</p>",
* format: "full_html",
* },
* },
* },
* },
* {
* params: {
* "fields[node--page]": "title,path",
* },
* }
* )
* ```
* Using TypeScript with DrupalNode
* ```
* import { DrupalNode } from "next-drupal"
* const page = await drupal.createResource<DrupalNode>("node--page", {
* data: {
* attributes: {
* title: "Page Title",
* body: {
* value: "<p>Content of body field</p>",
* format: "full_html",
* },
* },
* },
* })
* ```
*/
async createResource(type, body, options) {
options = {
deserialize: true,
withAuth: true,
...options
};
const endpoint = await this.buildEndpoint({
locale: options?.locale !== options?.defaultLocale ? (
/* c8 ignore next */
options.locale
) : void 0,
resourceType: type,
searchParams: options?.params
});
this.debug(`Creating resource of type ${type}.`);
body.data.type = type;
const response = await this.fetch(endpoint, {
method: "POST",
body: JSON.stringify(body),
withAuth: options.withAuth,
cache: options.cache
});
await this.throwIfJsonErrors(response, "Error while creating resource: ");
const json = await response.json();
return options.deserialize ? this.deserialize(json) : (
/* c8 ignore next */
json
);
}
/**
* Creates a new file resource for the specified media type.
*
* @param {string} type The type of the resource. In most cases this is `file--file`.
* @param {JsonApiCreateFileResourceBody} body The body payload with data.
* - type: The resource type of the host entity. Example: `media--image`
* - field: The name of the file field on the host entity. Example: `field_media_image`
* - filename: The name of the file with extension. Example: `avatar.jpg`
* - file: The file as a Buffer
* @param {JsonApiOptions} options Options for the request.
* @returns {Promise<T>} The created file resource.
* @example
* Create a file resource for a media--image entity
* ```ts
* const file = await drupal.createFileResource("file--file", {
* data: {
* attributes: {
* type: "media--image", // The type of the parent resource
* field: "field_media_image", // The name of the field on the parent resource
* filename: "filename.jpg",
* file: await fs.readFile("/path/to/file.jpg"),
* },
* },
* })
* ```
*
* You can then use this to create a new media--image with a relationship to the file:
* ```ts
* const media = await drupal.createResource<DrupalMedia>("media--image", {
* data: {
* attributes: {
* name: "Name for the media",
* },
* relationships: {
* field_media_image: {
* data: {
* type: "file--file",
* id: file.id,
* },
* },
* },
* },
* })
* ```
*/
async createFileResource(type, body, options) {
options = {
deserialize: true,
withAuth: true,
...options
};
const resourceType = body?.data?.attributes?.type;
const endpoint = await this.buildEndpoint({
locale: options?.locale !== options?.defaultLocale ? options.locale : void 0,
resourceType,
path: `/${body.data.attributes.field}`,
searchParams: options?.params
});
this.debug(`Creating file resource for media of type ${type}.`);
const response = await this.fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/octet-stream",
Accept: "application/vnd.api+json",
"Content-Disposition": `file; filename="${body.data.attributes.filename}"`
},
body: body.data.attributes.file,
withAuth: options.withAuth,
cache: options.cache
});
await this.throwIfJsonErrors(
response,
"Error while creating file resource: "
);
const json = await response.json();
return options.deserialize ? this.deserialize(json) : json;
}
/**
* Updates an existing resource of the specified type.
*
* @param {string} type The type of the resource. Example: `node--article`, `taxonomy_term--tags`, or `block_content--basic`.
* @param {string} uuid The resource id. Example: `a50ffee7-ba94-46c9-9705-f9f8f440db94`.
* @param {JsonApiUpdateResourceBody} body The body payload with data.
* @param {JsonApiOptions & JsonApiWithNextFetchOptions} options Options for the request.
* @returns {Promise<T>} The updated resource.
* @example
* Update a node--page resource
* ```ts
* const page = await drupal.updateResource(
* "node--page",
* "a50ffee7-ba94-46c9-9705-f9f8f440db94",
* {
* data: {
* attributes: {
* title: "Updated Title",
* },
* },
* }
* )
* ```
*
* Using TypeScript with DrupalNode for a node entity type
* ```ts
* import { DrupalNode } from "next-drupal"
*
* const page = await drupal.updateResource<DrupalNode>(
* "node--page",
* "a50ffee7-ba94-46c9-9705-f9f8f440db94",
* {
* data: {
* attributes: {
* title: "Updated Title",
* },
* },
* }
* )
* ```
*/
async updateResource(type, uuid, body, options) {
options = {
deserialize: true,
withAuth: true,
...options
};
const endpoint = await this.buildEndpoint({
locale: options?.locale !== options?.defaultLocale ? (
/* c8 ignore next */
options.locale
) : void 0,
resourceType: type,
path: `/${uuid}`,
searchParams: options?.params
});
this.debug(`Updating resource of type ${type} with id ${uuid}.`);
body.data.type = type;
body.data.id = uuid;
const response = await this.fetch(endpoint, {
method: "PATCH",
body: JSON.stringify(body),
withAuth: options.withAuth,
cache: options.cache
});
await this.throwIfJsonErrors(response, "Error while updating resource: ");
const json = await response.json();
return options.deserialize ? this.deserialize(json) : (
/* c8 ignore next */
json
);
}
/**
* Deletes an existing resource of the specified type.
*
* @param {string} type The type of the resource. Example: `node--article`, `taxonomy_term--tags`, or `block_content--basic`.
* @param {string} uuid The resource id. Example: `a50ffee7-ba94-46c9-9705-f9f8f440db94`.
* @param {JsonApiOptions & JsonApiWithNextFetchOptions} options Options for the request.
* @returns {Promise<boolean>} True if the resource was deleted, false otherwise.
* @example
* Delete a node--page resource
* ```ts
* const isDeleted = await drupal.deleteResource(
* "node--page",
* "a50ffee7-ba94-46c9-9705-f9f8f440db94"
* )
* ```
*/
async deleteResource(type, uuid, options) {
options = {
withAuth: true,
params: {},
...options
};
const endpoint = await this.buildEndpoint({
locale: options?.locale !== options?.defaultLocale ? (
/* c8 ignore next */
options.locale
) : void 0,
resourceType: type,
path: `/${uuid}`,
searchParams: options?.params
});
this.debug(`Deleting resource of type ${type} with id ${uuid}.`);
const response = await this.fetch(endpoint, {
method: "DELETE",
withAuth: options.withAuth,
cache: options.cache
});
await this.throwIfJsonErrors(response, "Error while deleting resource: ");
return response.status === 204;
}
/**
* Fetches a resource of the specified type by its UUID.
*
* @param {string} type The resource type. Example: `node--article`, `taxonomy_term--tags`, or `block_content--basic`.
* @param {string} uuid The id of the resource. Example: `15486935-24bf-4be7-b858-a5b2de78d09d`.
* @param {JsonApiOptions & JsonApiWithCacheOptions & JsonApiWithNextFetchOptions} options Options for the request.
* @returns {Promise<T>} The fetched resource.
* @examples
* Get a page by uuid.
* ```ts
* const node = await drupal.getResource(
* "node--page",
* "07464e9f-9221-4a4f-b7f2-01389408e6c8"
* )
* ```
* Get the es translation for a page by uuid.
* ```ts
* const node = await drupal.getResource(
* "node--page",
* "07464e9f-9221-4a4f-b7f2-01389408e6c8",
* {
* locale: "es",
* defaultLocale: "en",
* }
* )
* ```
* Get the raw JSON:API response.
* ```ts
* const { data, meta, links } = await drupal.getResource(
* "node--page",
* "07464e9f-9221-4a4f-b7f2-01389408e6c8",
* {
* deserialize: false,
* }
* )
* ```
* Get a node--article resource using cache.
* ```ts
* const id = "07464e9f-9221-4a4f-b7f2-01389408e6c8"
*
* const article = await drupal.getResource("node--article", id, {
* withCache: true,
* cacheKey: `node--article:${id}`,
* })
* ```
* Get a page resource with time-based revalidation.
* ```ts
* const node = await drupal.getResource(
* "node--page",
* "07464e9f-9221-4a4f-b7f2-01389408e6c8",
* { next: { revalidate: 3600 } }
* )
* ```
* Get a page resource with tag-based revalidation.
* ```ts
* const {slug} = params;
* const path = drupal.translatePath(slug)
*
* const type = path.jsonapi.resourceName
* const tag = `${path.entity.type}:${path.entity.id}`
*
* const node = await drupal.getResource(path, path.entity.uuid, {
* params: params.getQueryObject(),
* tags: [tag]
* })
* ```
* Using DrupalNode for a node entity type.
* ```ts
* import { DrupalNode } from "next-drupal"
*
* const node = await drupal.getResource<DrupalNode>(
* "node--page",
* "07464e9f-9221-4a4f-b7f2-01389408e6c8"
* )
* ```
* Using DrupalTaxonomyTerm for a taxonomy term entity type.
* ```ts
* import { DrupalTaxonomyTerm } from "next-drupal"
*
* const term = await drupal.getResource<DrupalTaxonomyTerm>(
* "taxonomy_term--tags",
* "7b47d7cc-9b1b-4867-a909-75dc1d61dfd3"
* )
* ```
*/
async getResource(type, uuid, options) {
options = {
deserialize: true,
withAuth: this.withAuth,
withCache: false,
params: {},
...options
};
if (options.withCache) {
const cached = await this.cache.get(options.cacheKey);
if (cached) {
this.debug(`Returning cached resource ${type} with id ${uuid}.`);
const json2 = JSON.parse(cached);
return options.deserialize ? this.deserialize(json2) : json2;
}
}
const endpoint = await this.buildEndpoint({
locale: options?.locale !== options?.defaultLocale ? options.locale : void 0,
resourceType: type,
path: `/${uuid}`,
searchParams: options?.params
});
this.debug(`Fetching resource ${type} with id ${uuid}.`);
const response = await this.fetch(endpoint, {
withAuth: options.withAuth,
next: options.next,
cache: options.cache
});
await this.throwIfJsonErrors(response, "Error while fetching resource: ");
const json = await response.json();
if (options.withCache) {
await this.cache.set(options.cacheKey, JSON.stringify(json));
}
return options.deserialize ? this.deserialize(json) : json;
}
/**
* Fetches a resource of the specified type by its path.
*
* @param {string} path The path of the resource. Example: `/blog/slug-for-article`.
* @param { { isVersionable?: boolean } & JsonApiOptions & JsonApiWithNextFetchOptions} options Options for the request.
* - isVersionable: Set to true if you're fetching the revision for a resource. Automatically set to true for node entity types
* @returns {Promise<T>} The fetched resource.
* @requires Decoupled Router module
* @example
* Get a page by path
* ```
* const node = await drupal.getResourceByPath("/blog/slug-for-article")
* ```
* Get the raw JSON:API response
* ```
* const { data, meta, links } = await drupal.getResourceByPath(
* "/blog/slug-for-article",
* {
* deserialize: false,
* }
* )
*```
* Using DrupalNode for a node entity type
* ```
* import { DrupalNode } from "next-drupal"
* const node = await drupal.getResourceByPath<DrupalNode>(
* "/blog/slug-for-article"
* )
* ```
*/
async getResourceByPath(path, options) {
options = {
deserialize: true,
isVersionable: false,
withAuth: this.withAuth,
params: {},
...options
};
if (!path) {
return null;
}
path = this.addLocalePrefix(path, {
locale: options.locale,
defaultLocale: options.defaultLocale
});
if (options.params.resourceVersion) {
options.isVersionable = true;
}
const { resourceVersion = "rel:latest-version", ...params } = options.params;
if (options.isVersionable) {
params.resourceVersion = resourceVersion;
}
const resourceParams = (0, import_qs2.stringify)(params);
const payload = [
{
requestId: "router",
action: "view",
uri: `/router/translate-path?path=${path}&_format=json`,
headers: { Accept: "application/vnd.api+json" }
},
{
requestId: "resolvedResource",
action: "view",
uri: `{{router.body@$.jsonapi.individual}}?${resourceParams.toString()}`,
waitFor: ["router"]
}
];
const subrequestsEndpoint = this.addLocalePrefix("/subrequests", {
locale: options.locale,
defaultLocale: options.defaultLocale
});
const endpoint = this.buildUrl(subrequestsEndpoint, {
_format: "json"
}).toString();
this.debug(`Fetching resource by path, ${path}.`);
const response = await this.fetch(endpoint, {
method: "POST",
credentials: "include",
redirect: "follow",
body: JSON.stringify(payload),
withAuth: options.withAuth,
next: options.next,
cache: options.cache
});
const errorMessagePrefix = "Error while fetching resource by path:";
if (response.status !== 207) {
const errors = await this.getErrorsFromResponse(response);
throw new JsonApiErrors(errors, response.status, errorMessagePrefix);
}
const json = await response.json();
if (!json?.["resolvedResource#uri{0}"]?.body) {
const status = json?.router?.headers?.status?.[0];
if (status === 404) {
return null;
}
const message = json?.router?.body && JSON.parse(json.router.body)?.message || "Unknown error";
throw new JsonApiErrors(message, status, errorMessagePrefix);
}
const data = JSON.parse(json["resolvedResource#uri{0}"]?.body);
if (data.errors) {
const status = json?.["resolvedResource#uri{0}"]?.headers?.status?.[0];
this.logOrThrowError(
new JsonApiErrors(data.errors, status, errorMessagePrefix)
);
}
return options.deserialize ? this.deserialize(data) : data;
}
/**
* Fetches a collection of resources of the specified type.
*
* @param {string} type The type of the resources. Example: `node--article` or `user--user`.
* @param {JsonApiOptions & JsonApiWithNextFetchOptions} options Options for the request.
* - deserialize: Set to false to return the raw JSON:API response
* @returns {Promise<T>} The fetched collection of resources.
* @example
* Get all articles
* ```
* const articles = await drupal.getResourceCollection("node--article")
* ```
* Using filters
* ```
* const publishedArticles = await drupal.getResourceCollection("node--article", {
* params: {
* "filter[status]": "1",
* },
* })
* ```
* Get the raw JSON:API response
* ```
* const { data, meta, links } = await drupal.getResourceCollection("node--page", {
* deserialize: false,
* })
* ```
* Using TypeScript with DrupalNode for a node entity type
* ```
* import { DrupalNode } from "next-drupal"
* const nodes = await drupal.getResourceCollection<DrupalNode[]>("node--article")
* ```
*/
async getResourceCollection(type, options) {
options = {
withAuth: this.withAuth,
deserialize: true,
...options
};
const endpoint = await this.buildEndpoint({
locale: options?.locale !== options?.defaultLocale ? options.locale : void 0,
resourceType: type,
searchParams: options?.params
});
this.debug(`Fetching resource collection of type ${type}.`);
const response = await this.fetch(endpoint, {
withAuth: options.withAuth,
next: options.next,
cache: options.cache
});
await this.throwIfJsonErrors(
response,
"Error while fetching resource collection: "
);
const json = await response.json();
return options.deserialize ? this.deserialize(json) : json;
}
/**
* Fetches path segments for a collection of resources of the specified types.
*
* @param {string | string[]} types The types of the resources.
* @param {JsonApiOptions & JsonApiWithAuthOption & JsonApiWithNextFetchOptions} options Options for the request.
* @returns {Promise<{ path: string, type: string, locale: Locale, segments: string[] }[]>} The fetched path segments.
*/
async getResourceCollectionPathSegments(types, options) {
options = {
withAuth: this.withAuth,
pathPrefix: "",
params: {},
...options
};
if (typeof types === "string") {
types = [types];
}
const paths = await Promise.all(
types.map(async (type) => {
const params = {
[`fields[${type}]`]: "path",
...options?.params
};
const locales = options?.locales?.length ? options.locales : [void 0];
return Promise.all(
locales.map(async (locale) => {
let opts = {
params,
withAuth: options.withAuth,
next: options.next,
cache: options.cache
};
if (locale) {
opts = {
...opts,
deserialize: true,
locale,
defaultLocale: options.defaultLocale
};
}
const resources = await this.getResourceCollection(type, opts);
return resources.map((resource) => {
return resource?.path?.alias === this.frontPage ? (
/* c8 ignore next */
"/"
) : resource?.path?.alias;
}).filter(Boolean).map((path) => {
let segmentPath = path;
if (options.pathPrefix && (segmentPath.startsWith(
`${options.pathPrefix}/`
) || segmentPath === options.pathPrefix)) {
segmentPath = segmentPath.slice(options.pathPrefix.length);
}
const segments = segmentPath.split("/").filter(Boolean);
return {
path,
type,
locale,
segments
};
});
})
);
})
);
return paths.flat(2);
}
/**
* Translates a path to a DrupalTranslatedPath object.
*
* @param {string} path The resource path. Example: `/blog/slug-for-article`.
* @param {JsonApiWithAuthOption & JsonApiWithNextFetchOptions} options Options for the request.
* @returns {Promise<DrupalTranslatedPath | null>} The translated path.
* @requires Decoupled Router module
* @example
* Get info about a `/blog/slug-for-article` path
* ```ts
* const path = await drupal.translatePath("/blog/slug-for-article")
* ```
*/
async translatePath(path, options) {
options = {
withAuth: this.withAuth,
...options
};
const endpoint = this.buildUrl("/router/translate-path", {
path
}).toString();
this.debug(`Fetching translated path, ${path}.`);
const response = await this.fetch(endpoint, {
withAuth: options.withAuth,
next: options.next,
cache: options.cache
});
if (response.status === 404) {
return null;
}
await this.throwIfJsonErrors(response);
return await response.json();
}
/**
* Fetches the JSON:API index.
*
* @param {Locale} locale The locale for the request.
* @param {JsonApiWithNextFetchOptions} options Options for the request.
* @returns {Promise<JsonApiResponse>} The JSON:API index.
*/
async getIndex(locale, options) {
const endpoint = await this.buildEndpoint({
locale
});
this.debug(`Fetching JSON:API index.`);
const response = await this.fetch(endpoint, {
// As per https://www.drupal.org/node/2984034 /jsonapi is public.
withAuth: false,
next: options?.next,
cache: options?.cache
});
await this.throwIfJsonErrors(
response,
`Failed to fetch JSON:API index at ${endpoint}: `
);
return await response.json();
}
/**
* Builds an endpoint URL for the specified parameters.
*
* @param {Parameters<NextDrupalBase["buildEndpoint"]>[0] & { resourceType?: string }} params The parameters for the endpoint.
* @returns {Promise<string>} The built endpoint URL.
*/
async buildEndpoint({
locale = "",
resourceType = "",
path = "",
searchParams
} = {}) {
let localeSegment = locale ? `/${locale}` : "";
let apiSegment = this.apiPrefix;
let resourceSegment = "";
if (resourceType) {
if (this.useDefaultEndpoints) {
const [id, bundle] = resourceType.split("--");
resourceSegment = `/${id}` + (bundle ? `/${bundle}` : "");
} else {
resourceSegment = (await this.fetchResourceEndpoint(resourceType, locale)).pathname;
localeSegment = "";
apiSegment = "";
}
}
if (path && !path.startsWith("/")) {
path = `/${path}`;
}
return this.buildUrl(
`${localeSegment}${apiSegment}${resourceSegment}${path}`,
searchParams
).toString();
}
/**
* Fetches the endpoint URL for the specified resource type.
*
* @param {string} type The type of the resource.
* @param {Locale} locale The locale for the request.
* @returns {Promise<URL>} The fetched endpoint URL.
*/
async fetchResourceEndpoint(type, locale) {
const index = await this.getIndex(locale);
const link = index.links?.[type];
if (!link) {
throw new Error(`Resource of type '${type}' not found.`);
}
const url = new URL(link.href);
if (locale && !url.pathname.startsWith(`/${locale}`)) {
url.pathname = `/${locale}${url.pathname}`;
}
return url;
}
/**
* Fetches a menu by its name.
*
* @param {string} menuName The name of the menu. Example: `main` or `footer`.
* @param {JsonApiOptions & JsonApiWithCacheOptions & JsonApiWithNextFetchOptions} options Options for the request.
* @returns {Promise<{ items: T[], tree: T[] }>} The fetched menu.
* - items: An array of `DrupalMenuLinkContent`
* - tree: An array of `DrupalMenuLinkContent` with children nested to match the hierarchy from Drupal
* @requires JSON:API Menu Items module
* @example
* Get the `main` menu
* ```ts
* const { menu, items } = await drupal.getMenu("main")
* ```
*
* Get the `main` menu using cache
* ```ts
* const menu = await drupal.getMenu("main", {
* withCache: true,
* cacheKey: "menu--main",
* })
* ```
*/
async getMenu(menuName, options) {
options = {
withAuth: this.withAuth,
deserialize: true,
params: {},
withCache: false,
...options
};
if (options.withCache) {
const cached = await this.cache.get(options.cacheKey);
if (cached) {
this.debug(`Returning cached menu items for ${menuName}.`);
return JSON.parse(cached);
}
}
const endpoint = await this.buildEndpoint({
locale: options?.locale !== options?.defaultLocale ? options.locale : void 0,
resourceType: "menu_items",
path: menuName,
searchParams: options.params
});
this.debug(`Fetching menu items for ${menuName}.`);
const response = await this.fetch(endpoint, {
withAuth: options.withAuth,
next: options.next,
cache: options.cache
});
await this.throwIfJsonErrors(response, "Error while fetching menu items: ");
const data = await response.json();
const items = options.deserialize ? this.deserialize(data) : (
/* c8 ignore next */
data
);
const tree = new DrupalMenuTree(items);
const menu = {
items,
tree: tree.length ? tree : void 0
};
if (options.withCache) {
await this.cache.set(options.cacheKey, JSON.stringify(menu));
}
return menu;
}
/**
* Fetches a view by its name.
*
* @param {string} name The name of the view and the display id. Example: `articles--promoted`.
* @param {JsonApiOptions & JsonApiWithNextFetchOptions} options Options for the request.
* @returns {Promise<DrupalView<T>>} The fetched view.
* @requires JSON:API Views module
* @example
* Get a view named `articles` and display id `promoted`
* ```ts
* const view = await drupal.getView("articles--promoted")
* ```
*
* Using sparse fieldsets to only fetch the title and body fields
* ```ts
* const view = await drupal.getView("articles--promoted", {
* params: {
* fields: {
* "node--article": "title,body",
* },
* },
* })
* ```
*
* Using TypeScript with DrupalNode for a node entity type
* ```ts
* import { DrupalNode } from "next-drupal"
*
* const view = await drupal.getView<DrupalNode>("articles--promoted")
* ```
*/
async getView(name, options) {
options = {
withAuth: this.withAuth,
deserialize: true,
params: {},
...options
};
const [viewId, displayId] = name.split("--");
const endpoint = await this.buildEndpoint({
locale: options?.locale !== options?.defaultLocale ? options.locale : void 0,
path: `/views/${viewId}/${displayId}`,
searchParams: options.params
});
this.debug(`Fetching view, ${viewId}.${displayId}.`);
const response = await this.fetch(endpoint, {
withAuth: options.withAuth,
next: options.next,
cache: options.cache
});
await this.throwIfJsonErrors(response, "Error while fetching view: ");
const data = await response.json();
const results = options.deserialize ? this.deserialize(data) : data;
return {
id: name,
results,
meta: data.meta,
links: data.links
};
}
/**
* Fetches a search index by its name.
*
* @param {string} name The name of the search index.
* @param {JsonApiOptions & JsonApiWithNextFetchOptions} options Options for the request