next-drupal
Version:
Helpers for Next.js + Drupal.
1,316 lines (1,171 loc) • 37.2 kB
text/typescript
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
}
}