UNPKG

next-drupal

Version:
1,316 lines (1,171 loc) 37.2 kB
import { Jsona } from "jsona" import { stringify } from "qs" import { JsonApiErrors } from "./jsonapi-errors" import { DrupalMenuTree } from "./menu-tree" import { NextDrupalBase } from "./next-drupal-base" import type { BaseUrl, DrupalFile, DrupalMenuItem, DrupalTranslatedPath, DrupalView, JsonApiCreateFileResourceBody, JsonApiCreateResourceBody, JsonApiOptions, JsonApiParams, JsonApiResource, JsonApiResourceWithPath, JsonApiResponse, JsonApiUpdateResourceBody, JsonApiWithAuthOption, JsonApiWithCacheOptions, JsonApiWithNextFetchOptions, JsonDeserializer, Locale, NextDrupalOptions, PathPrefix, } from "./types" const DEFAULT_API_PREFIX = "/jsonapi" // See https://jsonapi.org/format/#content-negotiation. const DEFAULT_HEADERS = { "Content-Type": "application/vnd.api+json", Accept: "application/vnd.api+json", } export function useJsonaDeserialize() { const jsonFormatter = new Jsona() return function jsonaDeserialize( body: Parameters<JsonDeserializer>[0], options: Parameters<JsonDeserializer>[1] ) { return jsonFormatter.deserialize(body, options) } } /** * The NextDrupal class extends the NextDrupalBase class and provides methods * for interacting with a Drupal backend. */ export class NextDrupal extends NextDrupalBase { cache?: NextDrupalOptions["cache"] deserializer: JsonDeserializer throwJsonApiErrors: boolean useDefaultEndpoints: boolean /** * 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: BaseUrl, options: NextDrupalOptions = {}) { super(baseUrl, options) const { apiPrefix = DEFAULT_API_PREFIX, cache = null, deserializer, headers = DEFAULT_HEADERS, throwJsonApiErrors = true, useDefaultEndpoints = true, } = options this.apiPrefix = apiPrefix this.cache = cache this.deserializer = deserializer ?? useJsonaDeserialize() this.headers = headers this.throwJsonApiErrors = !!throwJsonApiErrors this.useDefaultEndpoints = !!useDefaultEndpoints // Do not throw errors in production. 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<T extends JsonApiResource>( type: string, body: JsonApiCreateResourceBody, options?: JsonApiOptions & JsonApiWithNextFetchOptions ): Promise<T> { options = { deserialize: true, withAuth: true, ...options, } const endpoint = await this.buildEndpoint({ locale: options?.locale !== options?.defaultLocale ? /* c8 ignore next */ options.locale : undefined, resourceType: type, searchParams: options?.params, }) this.debug(`Creating resource of type ${type}.`) // Add type to body. 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<T = DrupalFile>( type: string, body: JsonApiCreateFileResourceBody, options?: JsonApiOptions & JsonApiWithNextFetchOptions ): Promise<T> { options = { deserialize: true, withAuth: true, ...options, } const resourceType = body?.data?.attributes?.type const endpoint = await this.buildEndpoint({ locale: options?.locale !== options?.defaultLocale ? options.locale : undefined, 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<T extends JsonApiResource>( type: string, uuid: string, body: JsonApiUpdateResourceBody, options?: JsonApiOptions & JsonApiWithNextFetchOptions ): Promise<T> { options = { deserialize: true, withAuth: true, ...options, } const endpoint = await this.buildEndpoint({ locale: options?.locale !== options?.defaultLocale ? /* c8 ignore next */ options.locale : undefined, resourceType: type, path: `/${uuid}`, searchParams: options?.params, }) this.debug(`Updating resource of type ${type} with id ${uuid}.`) // Update body. 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: string, uuid: string, options?: JsonApiOptions & JsonApiWithNextFetchOptions ): Promise<boolean> { options = { withAuth: true, params: {}, ...options, } const endpoint = await this.buildEndpoint({ locale: options?.locale !== options?.defaultLocale ? /* c8 ignore next */ options.locale : undefined, 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<T extends JsonApiResource>( type: string, uuid: string, options?: JsonApiOptions & JsonApiWithCacheOptions & JsonApiWithNextFetchOptions ): Promise<T> { options = { deserialize: true, withAuth: this.withAuth, withCache: false, params: {}, ...options, } /* c8 ignore next 11 */ if (options.withCache) { const cached = (await this.cache.get(options.cacheKey)) as string if (cached) { this.debug(`Returning cached resource ${type} with id ${uuid}.`) const json = JSON.parse(cached) return options.deserialize ? this.deserialize(json) : json } } const endpoint = await this.buildEndpoint({ locale: options?.locale !== options?.defaultLocale ? options.locale : undefined, 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() /* c8 ignore next 3 */ 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<T extends JsonApiResource>( path: string, options?: { isVersionable?: boolean } & JsonApiOptions & JsonApiWithNextFetchOptions ): Promise<T> { 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 a resourceVersion is provided, assume entity type is versionable. if (options.params.resourceVersion) { options.isVersionable = true } const { resourceVersion = "rel:latest-version", ...params } = options.params if (options.isVersionable) { params.resourceVersion = resourceVersion } const resourceParams = stringify(params) // We are intentionally not using translatePath here. // We want a single request using subrequests. 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"], }, ] // Handle localized subrequests. It seems like subrequests is not properly // setting the jsonapi locale from a translated path. // TODO: Confirm we still need this after https://www.drupal.org/i/3111456 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<T = JsonApiResource[]>( type: string, options?: { deserialize?: boolean } & JsonApiOptions & JsonApiWithNextFetchOptions ): Promise<T> { options = { withAuth: this.withAuth, deserialize: true, ...options, } const endpoint = await this.buildEndpoint({ locale: options?.locale !== options?.defaultLocale ? options.locale : undefined, 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: string | string[], options?: { pathPrefix?: PathPrefix params?: JsonApiParams } & JsonApiWithAuthOption & JsonApiWithNextFetchOptions & ( | { locales: Locale[] defaultLocale: Locale } | { locales?: undefined defaultLocale?: never } ) ): Promise< { path: string type: string locale: Locale segments: string[] }[] > { options = { withAuth: this.withAuth, pathPrefix: "", params: {}, ...options, } if (typeof types === "string") { types = [types] } const paths = await Promise.all( types.map(async (type) => { // Use sparse fieldset to expand max size. // Note we don't need status filter here since this runs non-authenticated (by default). const params = { [`fields[${type}]`]: "path", ...options?.params, } const locales = options?.locales?.length ? options.locales : [undefined] return Promise.all( locales.map(async (locale) => { let opts: Parameters<NextDrupal["getResourceCollection"]>[1] = { 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< JsonApiResourceWithPath[] >(type, opts) return ( resources .map((resource) => { return resource?.path?.alias === this.frontPage ? /* c8 ignore next */ "/" : resource?.path?.alias }) // Remove results with no path aliases. .filter(Boolean) .map((path) => { let segmentPath = path // Trim the pathPrefix off the front of the path. if ( options.pathPrefix && (segmentPath.startsWith( `${options.pathPrefix}/` ) /* c8 ignore next */ || segmentPath === options.pathPrefix) ) { segmentPath = segmentPath.slice(options.pathPrefix.length) } // Convert the trimmed path into an array of path segments. 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: string, options?: JsonApiWithAuthOption & JsonApiWithNextFetchOptions ): Promise<DrupalTranslatedPath | null> { 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) { // Do not throw errors here, otherwise Next.js will catch the error and // throw a 500. We want a 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?: Locale, options?: JsonApiWithNextFetchOptions ): Promise<JsonApiResponse> { 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, }: Parameters<NextDrupalBase["buildEndpoint"]>[0] & { resourceType?: string } = {}): Promise<string> { let localeSegment = locale ? `/${locale}` : "" let apiSegment = this.apiPrefix // Determine the optional resource part of the endpoint URL. let resourceSegment = "" if (resourceType) { if (this.useDefaultEndpoints) { const [id, bundle] = resourceType.split("--") resourceSegment = `/${id}` + (bundle ? `/${bundle}` : "") } else { resourceSegment = ( await this.fetchResourceEndpoint(resourceType, locale) ).pathname // Fetched endpoint URLs already include the apiPrefix and the locale. 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: string, locale?: Locale): Promise<URL> { const index = await this.getIndex(locale) const link = index.links?.[type] as { href: string } if (!link) { throw new Error(`Resource of type '${type}' not found.`) } const url = new URL(link.href) // TODO: Is this "fix" needed any more? Drupal 9.4 and later don't exhibit // this behavior. // Fix for missing locale in JSON:API index. // This fix ensures the locale is included in the resource link. /* c8 ignore next 3 */ 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<T = DrupalMenuItem>( menuName: string, options?: JsonApiOptions & JsonApiWithCacheOptions & JsonApiWithNextFetchOptions ): Promise<{ items: T[] tree: T[] }> { options = { withAuth: this.withAuth, deserialize: true, params: {}, withCache: false, ...options, } /* c8 ignore next 9 */ if (options.withCache) { const cached = (await this.cache.get(options.cacheKey)) as string 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 : undefined, 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 : undefined, } /* c8 ignore next 3 */ 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<T = JsonApiResource>( name: string, options?: JsonApiOptions & JsonApiWithNextFetchOptions ): Promise<DrupalView<T>> { 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 : undefined, 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. * @returns {Promise<T>} The fetched search index. * @requires JSON:API Search API module * @example * Get search results from an index named `articles` * ```ts * const results = await drupal.getSearchIndex("articles") * ``` * * Using TypeScript with DrupalNode for a node entity type * ```ts * import { DrupalNode } from "next-drupal" * * const results = await drupal.getSearchIndex<DrupalNode>("articles") * ``` */ async getSearchIndex<T = JsonApiResource[]>( name: string, options?: JsonApiOptions & JsonApiWithNextFetchOptions ): Promise<T> { options = { withAuth: this.withAuth, deserialize: true, ...options, } const endpoint = await this.buildEndpoint({ locale: options?.locale !== options?.defaultLocale ? options.locale : undefined, path: `/index/${name}`, searchParams: options.params, }) this.debug(`Fetching search index, ${name}.`) const response = await this.fetch(endpoint, { withAuth: options.withAuth, next: options.next, cache: options.cache, }) await this.throwIfJsonErrors( response, "Error while fetching search index: " ) const json = await response.json() return options.deserialize ? this.deserialize(json) : json } /** * Deserializes the response body. * * @param {any} body The response body. * @param {any} options Options for deserialization. * @returns {any} The deserialized response body. * @remarks * To provide your own custom deserializer, see the serializer docs. * @example * ```ts * const url = drupal.buildUrl("/jsonapi/node/article", { * sort: "-created", * "fields[node--article]": "title,path", * }) * * const response = await drupal.fetch(url.toString()) * const json = await response.json() * * const resource = drupal.deserialize(json) * ``` */ deserialize(body, options?) { if (!body) return null return this.deserializer(body, options) } /** * Logs or throws an error based on the throwJsonApiErrors flag. * * @param {Error} error The error to log or throw. */ logOrThrowError(error: Error) { if (!this.throwJsonApiErrors) { this.logger.error(error) return } throw error } }