From c9ae778b02939b16a51db3d6de4691396e2ca592 Mon Sep 17 00:00:00 2001 From: JohnAlbin Date: Thu, 7 Mar 2024 23:08:21 +0800 Subject: [PATCH] feat(next-drupal): add NextDrupal and NextDrupalBase class definitions The DrupalClient class has been renamed to NextDrupalPages and has been refactored to inherit from the NextDrupalBase (base class) and NextDrupal (JsonAPI/App Router class). NextDrupalPages class contains the methods that are only needed to support Next.js' Pages Router. NextDrupalPages is also available as "DrupalClient" for backwards-compatibility. The getPathFromContext() method has been replaced with the constructPathFromSegment() method for App Router usages. Issue #665 --- packages/next-drupal/jest.config.cjs | 2 + packages/next-drupal/src/client.ts | 1514 ----------------- packages/next-drupal/src/deprecated.ts | 3 + .../next-drupal/src/deprecated/get-menu.ts | 8 +- .../next-drupal/src/deprecated/use-menu.ts | 4 +- packages/next-drupal/src/draft-constants.ts | 4 + packages/next-drupal/src/draft.ts | 9 +- packages/next-drupal/src/index.ts | 5 +- packages/next-drupal/src/jsonapi-errors.ts | 12 +- packages/next-drupal/src/menu-tree.ts | 48 + packages/next-drupal/src/next-drupal-base.ts | 534 ++++++ packages/next-drupal/src/next-drupal-pages.ts | 473 +++++ packages/next-drupal/src/next-drupal.ts | 690 ++++++++ packages/next-drupal/src/types/deprecated.ts | 5 +- packages/next-drupal/src/types/drupal.ts | 22 +- packages/next-drupal/src/types/index.ts | 4 +- .../types/{client.ts => next-drupal-base.ts} | 139 +- .../src/types/next-drupal-pages.ts | 41 + packages/next-drupal/src/types/next-drupal.ts | 57 + packages/next-drupal/src/types/options.ts | 6 +- packages/next-drupal/src/types/resource.ts | 4 +- .../tests/DrupalClient/basic-methods.test.ts | 401 ----- .../tests/DrupalClient/constructor.test.ts | 316 ---- .../tests/DrupalClient/fetch-methods.test.ts | 411 ----- .../DrupalClient/getters-setters.test.ts | 223 --- .../DrupalMenuTree/drupal-menu-tree.test.ts | 101 ++ .../__snapshots__/basic-methods.test.ts.snap | 0 .../resource-methods.test.ts.snap | 14 +- .../tests/NextDrupal/basic-methods.test.ts | 107 ++ .../tests/NextDrupal/constructor.test.ts | 208 +++ .../crud-methods.test.ts | 95 +- .../resource-methods.test.ts | 496 +++--- .../NextDrupalBase/auth-functions.test.ts | 77 + .../NextDrupalBase/basic-methods.test.ts | 497 ++++++ .../tests/NextDrupalBase/constructor.test.ts | 218 +++ .../NextDrupalBase/fetch-methods.test.ts | 573 +++++++ .../NextDrupalBase/getters-setters.test.ts | 264 +++ .../pages-router-methods.test.ts.snap | 0 .../tests/NextDrupalPages/constructor.test.ts | 43 + .../pages-router-methods.test.ts | 632 +++---- .../NextDrupalPages/resource-methods.test.ts | 37 + .../next-drupal/tests/draft/draft.test.ts | 14 +- .../next-drupal/tests/utils/mocks/fetch.ts | 10 +- packages/next-drupal/tests/utils/rpc.ts | 40 +- 44 files changed, 4714 insertions(+), 3647 deletions(-) delete mode 100644 packages/next-drupal/src/client.ts create mode 100644 packages/next-drupal/src/draft-constants.ts create mode 100644 packages/next-drupal/src/menu-tree.ts create mode 100644 packages/next-drupal/src/next-drupal-base.ts create mode 100644 packages/next-drupal/src/next-drupal-pages.ts create mode 100644 packages/next-drupal/src/next-drupal.ts rename packages/next-drupal/src/types/{client.ts => next-drupal-base.ts} (57%) create mode 100644 packages/next-drupal/src/types/next-drupal-pages.ts create mode 100644 packages/next-drupal/src/types/next-drupal.ts delete mode 100644 packages/next-drupal/tests/DrupalClient/basic-methods.test.ts delete mode 100644 packages/next-drupal/tests/DrupalClient/constructor.test.ts delete mode 100644 packages/next-drupal/tests/DrupalClient/fetch-methods.test.ts delete mode 100644 packages/next-drupal/tests/DrupalClient/getters-setters.test.ts create mode 100644 packages/next-drupal/tests/DrupalMenuTree/drupal-menu-tree.test.ts rename packages/next-drupal/tests/{DrupalClient => NextDrupal}/__snapshots__/basic-methods.test.ts.snap (100%) rename packages/next-drupal/tests/{DrupalClient => NextDrupal}/__snapshots__/resource-methods.test.ts.snap (99%) create mode 100644 packages/next-drupal/tests/NextDrupal/basic-methods.test.ts create mode 100644 packages/next-drupal/tests/NextDrupal/constructor.test.ts rename packages/next-drupal/tests/{DrupalClient => NextDrupal}/crud-methods.test.ts (80%) rename packages/next-drupal/tests/{DrupalClient => NextDrupal}/resource-methods.test.ts (55%) create mode 100644 packages/next-drupal/tests/NextDrupalBase/auth-functions.test.ts create mode 100644 packages/next-drupal/tests/NextDrupalBase/basic-methods.test.ts create mode 100644 packages/next-drupal/tests/NextDrupalBase/constructor.test.ts create mode 100644 packages/next-drupal/tests/NextDrupalBase/fetch-methods.test.ts create mode 100644 packages/next-drupal/tests/NextDrupalBase/getters-setters.test.ts rename packages/next-drupal/tests/{DrupalClient => NextDrupalPages}/__snapshots__/pages-router-methods.test.ts.snap (100%) create mode 100644 packages/next-drupal/tests/NextDrupalPages/constructor.test.ts rename packages/next-drupal/tests/{DrupalClient => NextDrupalPages}/pages-router-methods.test.ts (65%) create mode 100644 packages/next-drupal/tests/NextDrupalPages/resource-methods.test.ts diff --git a/packages/next-drupal/jest.config.cjs b/packages/next-drupal/jest.config.cjs index a7ccdfe7..457ff9b9 100644 --- a/packages/next-drupal/jest.config.cjs +++ b/packages/next-drupal/jest.config.cjs @@ -14,6 +14,8 @@ module.exports = { ], }, testLocationInResults: true, + // TODO: Remove prettierPath after Jest v30 release. See https://github.com/jestjs/jest/issues/14305 + prettierPath: null, coverageProvider: "v8", collectCoverage: true, collectCoverageFrom: ["./src/**"], diff --git a/packages/next-drupal/src/client.ts b/packages/next-drupal/src/client.ts deleted file mode 100644 index d355f532..00000000 --- a/packages/next-drupal/src/client.ts +++ /dev/null @@ -1,1514 +0,0 @@ -import { Jsona } from "jsona" -import { stringify } from "qs" -import { JsonApiErrors } from "./jsonapi-errors" -import { logger as defaultLogger } from "./logger" -import type { - GetStaticPathsContext, - GetStaticPathsResult, - GetStaticPropsContext, - NextApiRequest, - NextApiResponse, -} from "next" -import type { - AccessToken, - BaseUrl, - DrupalClientAuth, - DrupalClientAuthAccessToken, - DrupalClientAuthClientIdSecret, - DrupalClientAuthUsernamePassword, - DrupalClientOptions, - DrupalFile, - DrupalMenuLinkContent, - DrupalTranslatedPath, - DrupalView, - FetchOptions, - JsonApiCreateFileResourceBody, - JsonApiCreateResourceBody, - JsonApiOptions, - JsonApiParams, - JsonApiResource, - JsonApiResourceWithPath, - JsonApiResponse, - JsonApiUpdateResourceBody, - JsonApiWithAuthOption, - JsonApiWithCacheOptions, - Locale, - PathAlias, - PathPrefix, -} from "./types" - -const DEFAULT_API_PREFIX = "/jsonapi" -const DEFAULT_FRONT_PAGE = "/home" -const DEFAULT_WITH_AUTH = false -export const DRAFT_DATA_COOKIE_NAME = "draftData" -// See https://vercel.com/docs/workflow-collaboration/draft-mode -export const DRAFT_MODE_COOKIE_NAME = "__prerender_bypass" - -// From simple_oauth. -const DEFAULT_AUTH_URL = "/oauth/token" - -// See https://jsonapi.org/format/#content-negotiation. -const DEFAULT_HEADERS = { - "Content-Type": "application/vnd.api+json", - Accept: "application/vnd.api+json", -} - -function isBasicAuth( - auth: DrupalClientAuth -): auth is DrupalClientAuthUsernamePassword { - return ( - (auth as DrupalClientAuthUsernamePassword)?.username !== undefined && - (auth as DrupalClientAuthUsernamePassword)?.password !== undefined - ) -} - -function isAccessTokenAuth( - auth: DrupalClientAuth -): auth is DrupalClientAuthAccessToken { - return ( - (auth as DrupalClientAuthAccessToken)?.access_token !== undefined && - (auth as DrupalClientAuthAccessToken)?.token_type !== undefined - ) -} - -function isClientIdSecretAuth( - auth: DrupalClientAuth -): auth is DrupalClientAuthClientIdSecret { - return ( - (auth as DrupalClientAuthClientIdSecret)?.clientId !== undefined && - (auth as DrupalClientAuthClientIdSecret)?.clientSecret !== undefined - ) -} - -export class DrupalClient { - baseUrl: BaseUrl - - frontPage: DrupalClientOptions["frontPage"] - - private isDebugEnabled: DrupalClientOptions["debug"] - - private serializer: DrupalClientOptions["serializer"] - - private cache: DrupalClientOptions["cache"] - - private throwJsonApiErrors?: DrupalClientOptions["throwJsonApiErrors"] - - private logger: DrupalClientOptions["logger"] - - private fetcher?: DrupalClientOptions["fetcher"] - - private _headers?: DrupalClientOptions["headers"] - - private _auth?: DrupalClientOptions["auth"] - - private _apiPrefix: DrupalClientOptions["apiPrefix"] - - private useDefaultResourceTypeEntry?: DrupalClientOptions["useDefaultResourceTypeEntry"] - - private _token?: AccessToken - - private accessToken?: DrupalClientOptions["accessToken"] - - private accessTokenScope?: DrupalClientOptions["accessTokenScope"] - - private tokenExpiresOn?: number - - private withAuth?: DrupalClientOptions["withAuth"] - - /** - * Instantiates a new DrupalClient. - * - * const client = new DrupalClient(baseUrl) - * - * @param {baseUrl} baseUrl The baseUrl of your Drupal site. Do not add the /jsonapi suffix. - * @param {options} options Options for the client. See Experiment_DrupalClientOptions. - */ - constructor(baseUrl: BaseUrl, options: DrupalClientOptions = {}) { - if (!baseUrl || typeof baseUrl !== "string") { - throw new Error("The 'baseUrl' param is required.") - } - - const { - apiPrefix = DEFAULT_API_PREFIX, - serializer = new Jsona(), - cache = null, - debug = false, - frontPage = DEFAULT_FRONT_PAGE, - useDefaultResourceTypeEntry = false, - headers = DEFAULT_HEADERS, - logger = defaultLogger, - withAuth = DEFAULT_WITH_AUTH, - fetcher, - auth, - accessToken, - throwJsonApiErrors = true, - } = options - - this.baseUrl = baseUrl - this.apiPrefix = apiPrefix - this.serializer = serializer - this.frontPage = frontPage - this.isDebugEnabled = !!debug - this.useDefaultResourceTypeEntry = useDefaultResourceTypeEntry - this.fetcher = fetcher - this.auth = auth - this.headers = headers - this.logger = logger - this.withAuth = withAuth - this.cache = cache - this.accessToken = accessToken - this.throwJsonApiErrors = throwJsonApiErrors - - // Do not throw errors in production. - if (process.env.NODE_ENV === "production") { - this.throwJsonApiErrors = false - } - - this.debug("Debug mode is on.") - } - - set apiPrefix(apiPrefix: DrupalClientOptions["apiPrefix"]) { - this._apiPrefix = apiPrefix.charAt(0) === "/" ? apiPrefix : `/${apiPrefix}` - } - - get apiPrefix() { - return this._apiPrefix - } - - set auth(auth: DrupalClientOptions["auth"]) { - if (typeof auth === "object") { - const checkUsernamePassword = auth as DrupalClientAuthUsernamePassword - const checkAccessToken = auth as DrupalClientAuthAccessToken - const checkClientIdSecret = auth as DrupalClientAuthClientIdSecret - - if ( - checkUsernamePassword.username !== undefined || - checkUsernamePassword.password !== undefined - ) { - 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 !== undefined || - checkAccessToken.token_type !== undefined - ) { - 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 - } - } - - set headers(value: DrupalClientOptions["headers"]) { - this._headers = value - } - - private set token(token: AccessToken) { - this._token = token - this.tokenExpiresOn = Date.now() + token.expires_in * 1000 - } - - async fetch( - input: RequestInfo, - { withAuth, ...init }: FetchOptions = {} - ): Promise { - init = { - ...init, - credentials: "include", - headers: { - ...this._headers, - ...init?.headers, - }, - } - - if (withAuth) { - init.headers["Authorization"] = await this.getAuthorizationHeader( - withAuth === true ? this._auth : withAuth - ) - } - - 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) - } - - async getAuthorizationHeader(auth: DrupalClientAuth) { - let header: string - - 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)) { - // Fetch an access token and add it to the request. getAccessToken() - // throws an error if it fails to get an access token. - 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 - } - - async createResource( - type: string, - body: JsonApiCreateResourceBody, - options?: JsonApiOptions - ): Promise { - options = { - deserialize: true, - withAuth: true, - ...options, - } - - const apiPath = await this.getEntryForResourceType( - type, - options?.locale !== options?.defaultLocale - ? /* c8 ignore next */ options.locale - : undefined - ) - - const url = this.buildUrl(apiPath, options?.params) - - this.debug(`Creating resource of type ${type}.`) - - // Add type to body. - body.data.type = type - - const response = await this.fetch(url.toString(), { - method: "POST", - body: JSON.stringify(body), - withAuth: options.withAuth, - }) - - await this.throwIfJsonApiErrors(response) - - const json = await response.json() - - return options.deserialize - ? this.deserialize(json) - : /* c8 ignore next */ json - } - - async createFileResource( - type: string, - body: JsonApiCreateFileResourceBody, - options?: JsonApiOptions - ): Promise { - options = { - deserialize: true, - withAuth: true, - ...options, - } - - const hostType = body?.data?.attributes?.type - - const apiPath = await this.getEntryForResourceType( - hostType, - options?.locale !== options?.defaultLocale ? options.locale : undefined - ) - - const url = this.buildUrl( - `${apiPath}/${body.data.attributes.field}`, - options?.params - ) - - this.debug(`Creating file resource for media of type ${type}.`) - - const response = await this.fetch(url.toString(), { - 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, - }) - - await this.throwIfJsonApiErrors(response) - - const json = await response.json() - - return options.deserialize ? this.deserialize(json) : json - } - - async updateResource( - type: string, - uuid: string, - body: JsonApiUpdateResourceBody, - options?: JsonApiOptions - ): Promise { - options = { - deserialize: true, - withAuth: true, - ...options, - } - - const apiPath = await this.getEntryForResourceType( - type, - options?.locale !== options?.defaultLocale - ? /* c8 ignore next */ options.locale - : undefined - ) - - const url = this.buildUrl(`${apiPath}/${uuid}`, 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(url.toString(), { - method: "PATCH", - body: JSON.stringify(body), - withAuth: options.withAuth, - }) - - await this.throwIfJsonApiErrors(response) - - const json = await response.json() - - return options.deserialize - ? this.deserialize(json) - : /* c8 ignore next */ json - } - - async deleteResource( - type: string, - uuid: string, - options?: JsonApiOptions - ): Promise { - options = { - withAuth: true, - params: {}, - ...options, - } - - const apiPath = await this.getEntryForResourceType( - type, - options?.locale !== options?.defaultLocale - ? /* c8 ignore next */ options.locale - : undefined - ) - - const url = this.buildUrl(`${apiPath}/${uuid}`, options?.params) - - this.debug(`Deleting resource of type ${type} with id ${uuid}.`) - - const response = await this.fetch(url.toString(), { - method: "DELETE", - withAuth: options.withAuth, - }) - - await this.throwIfJsonApiErrors(response) - - return response.status === 204 - } - - async getResource( - type: string, - uuid: string, - options?: JsonApiOptions & JsonApiWithCacheOptions - ): Promise { - 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 apiPath = await this.getEntryForResourceType( - type, - options?.locale !== options?.defaultLocale ? options.locale : undefined - ) - - const url = this.buildUrl(`${apiPath}/${uuid}`, options?.params) - - this.debug(`Fetching resource ${type} with id ${uuid}.`) - - const response = await this.fetch(url.toString(), { - withAuth: options.withAuth, - }) - - await this.throwIfJsonApiErrors(response) - - 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 - } - - async getResourceFromContext( - input: string | DrupalTranslatedPath, - context: GetStaticPropsContext, - options?: { - pathPrefix?: PathPrefix - isVersionable?: boolean - } & JsonApiOptions - ): Promise { - const type = typeof input === "string" ? input : input.jsonapi.resourceName - - const previewData = context.previewData as { - resourceVersion?: string - } - - options = { - deserialize: true, - pathPrefix: "/", - withAuth: this.getAuthFromContextAndOptions(context, options), - params: {}, - ...options, - } - - const _options = { - deserialize: options.deserialize, - isVersionable: options.isVersionable, - locale: context.locale, - defaultLocale: context.defaultLocale, - withAuth: options?.withAuth, - params: options?.params, - } - - // Check if resource is versionable. - // Add support for revisions for node by default. - const isVersionable = options.isVersionable || /^node--/.test(type) - - // If the resource is versionable and no resourceVersion is supplied via params. - // Use the resourceVersion from previewData or fallback to the latest version. - if ( - isVersionable && - typeof options.params.resourceVersion === "undefined" - ) { - options.params.resourceVersion = - previewData?.resourceVersion || "rel:latest-version" - } - - if (typeof input !== "string") { - // Fix for subrequests and translation. - // TODO: Confirm if we still need this after https://www.drupal.org/i/3111456. - // Given an entity at /example with no translation. - // When we try to translate /es/example, decoupled router will properly - // translate to the untranslated version and set the locale to es. - // However a subrequests to /es/subrequests for decoupled router will fail. - /* c8 ignore next 3 */ - if (context.locale && input.entity.langcode !== context.locale) { - context.locale = input.entity.langcode - } - - // Given we already have the path info, we can skip subrequests and just make a simple - // request to the Drupal site to get the entity. - if (input.entity?.uuid) { - return await this.getResource(type, input.entity.uuid, _options) - } - } - - const path = this.getPathFromContext(context, { - pathPrefix: options?.pathPrefix, - }) - - const resource = await this.getResourceByPath(path, _options) - - // If no locale is passed, skip entity if not default_langcode. - // This happens because decoupled_router will still translate the path - // to a resource. - // TODO: Figure out if we want this behavior. - // For now this causes a bug where a non-i18n sites builds (ISR) pages for - // localized pages. - // if (!context.locale && !resource?.default_langcode) { - // return null - // } - - return resource - } - - async getResourceByPath( - path: string, - options?: { - isVersionable?: boolean - } & JsonApiOptions - ): Promise { - options = { - deserialize: true, - isVersionable: false, - withAuth: this.withAuth, - params: {}, - ...options, - } - - if (!path) { - return null - } - - if ( - options.locale && - options.defaultLocale && - path.indexOf(options.locale) !== 1 - ) { - path = path === "/" ? /* c8 ignore next */ path : path.replace(/^\/+/, "") - path = this.getPathFromContext({ - params: { slug: [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"], - }, - ] - - // Localized subrequests. - // I was hoping we would not need this but it seems like subrequests is not properly - // setting the jsonapi locale from a translated path. - // TODO: Confirm if we still need this after https://www.drupal.org/i/3111456. - let subrequestsPath = "/subrequests" - if ( - options.locale && - options.defaultLocale && - options.locale !== options.defaultLocale - ) { - subrequestsPath = `/${options.locale}/subrequests` - } - - const url = this.buildUrl(subrequestsPath, { - _format: "json", - }) - - this.debug(`Fetching resource by path, ${path}.`) - - const response = await this.fetch(url.toString(), { - method: "POST", - credentials: "include", - redirect: "follow", - body: JSON.stringify(payload), - withAuth: options.withAuth, - }) - - const json = await response.json() - - if (!json?.["resolvedResource#uri{0}"]?.body) { - if (json?.router?.body) { - const error = JSON.parse(json.router.body) - if (error?.message) { - this.throwError(new Error(error.message)) - } - } - - return null - } - - const data = JSON.parse(json["resolvedResource#uri{0}"]?.body) - - if (data.errors) { - this.throwError(new Error(this.formatJsonApiErrors(data.errors))) - } - - return options.deserialize ? this.deserialize(data) : data - } - - async getResourceCollection( - type: string, - options?: { - deserialize?: boolean - } & JsonApiOptions - ): Promise { - options = { - withAuth: this.withAuth, - deserialize: true, - ...options, - } - - const apiPath = await this.getEntryForResourceType( - type, - options?.locale !== options?.defaultLocale ? options.locale : undefined - ) - - const url = this.buildUrl(apiPath, { - ...options?.params, - }) - - this.debug(`Fetching resource collection of type ${type}.`) - - const response = await this.fetch(url.toString(), { - withAuth: options.withAuth, - }) - - await this.throwIfJsonApiErrors(response) - - const json = await response.json() - - return options.deserialize ? this.deserialize(json) : json - } - - async getResourceCollectionFromContext( - type: string, - context: GetStaticPropsContext, - options?: { - deserialize?: boolean - } & JsonApiOptions - ): Promise { - options = { - deserialize: true, - ...options, - } - - return await this.getResourceCollection(type, { - ...options, - locale: context.locale, - defaultLocale: context.defaultLocale, - withAuth: this.getAuthFromContextAndOptions(context, options), - }) - } - - getPathsFromContext = this.getStaticPathsFromContext - - async getStaticPathsFromContext( - types: string | string[], - context: GetStaticPathsContext, - options?: { - params?: JsonApiParams - pathPrefix?: PathPrefix - } & JsonApiWithAuthOption - ): Promise["paths"]> { - 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, - } - - // Handle localized path aliases - if (!context.locales?.length) { - const resources = await this.getResourceCollection< - JsonApiResourceWithPath[] - >(type, { - params, - withAuth: options.withAuth, - }) - - return this.buildStaticPathsFromResources(resources, { - pathPrefix: options.pathPrefix, - }) - } - - const paths = await Promise.all( - context.locales.map(async (locale) => { - const resources = await this.getResourceCollection< - JsonApiResourceWithPath[] - >(type, { - deserialize: true, - locale, - defaultLocale: context.defaultLocale, - params, - withAuth: options.withAuth, - }) - - return this.buildStaticPathsFromResources(resources, { - locale, - pathPrefix: options.pathPrefix, - }) - }) - ) - - return paths.flat() - }) - ) - - return paths.flat() - } - - buildStaticPathsFromResources( - resources: { - path: PathAlias - }[], - options?: { - pathPrefix?: PathPrefix - locale?: Locale - } - ) { - const paths = resources - ?.flatMap((resource) => { - return resource?.path?.alias === this.frontPage - ? "/" - : resource?.path?.alias - }) - .filter(Boolean) - - return paths?.length - ? this.buildStaticPathsParamsFromPaths(paths, options) - : [] - } - - buildStaticPathsParamsFromPaths( - paths: string[], - options?: { pathPrefix?: PathPrefix; locale?: Locale } - ) { - return paths.flatMap((_path) => { - _path = _path.replace(/^\/|\/$/g, "") - - // Remove pathPrefix. - if (options?.pathPrefix && options.pathPrefix !== "/") { - // Remove leading slash from pathPrefix. - const pathPrefix = options.pathPrefix.replace(/^\//, "") - - _path = _path.replace(`${pathPrefix}/`, "") - } - - const path = { - params: { - slug: _path.split("/"), - }, - } - - if (options?.locale) { - path["locale"] = options.locale - } - - return path - }) - } - - async translatePath( - path: string, - options?: JsonApiWithAuthOption - ): Promise { - options = { - withAuth: this.withAuth, - ...options, - } - - const url = this.buildUrl("/router/translate-path", { - path, - }) - - this.debug(`Fetching translated path, ${path}.`) - - const response = await this.fetch(url.toString(), { - withAuth: options.withAuth, - }) - - if (!response?.ok) { - // Do not throw errors here. - // Otherwise next.js will catch error and throw a 500. - // We want a 404. - return null - } - - const json = await response.json() - - return json - } - - async translatePathFromContext( - context: GetStaticPropsContext, - options?: { - pathPrefix?: PathPrefix - } & JsonApiWithAuthOption - ): Promise { - options = { - pathPrefix: "/", - ...options, - } - const path = this.getPathFromContext(context, { - pathPrefix: options.pathPrefix, - }) - - return await this.translatePath(path, { - withAuth: this.getAuthFromContextAndOptions(context, options), - }) - } - - getPathFromContext( - context: GetStaticPropsContext, - options?: { - pathPrefix?: PathPrefix - } - ) { - options = { - pathPrefix: "/", - ...options, - } - - let slug = context.params?.slug - - let pathPrefix = - options.pathPrefix?.charAt(0) === "/" - ? options.pathPrefix - : `/${options.pathPrefix}` - - // Handle locale. - if (context.locale && context.locale !== context.defaultLocale) { - pathPrefix = `/${context.locale}${pathPrefix}` - } - - slug = Array.isArray(slug) - ? slug.map((s) => encodeURIComponent(s)).join("/") - : slug - - // Handle front page. - if (!slug) { - slug = this.frontPage - pathPrefix = pathPrefix.replace(/\/$/, "") - } - - slug = - pathPrefix.slice(-1) !== "/" && slug.charAt(0) !== "/" ? `/${slug}` : slug - - return `${pathPrefix}${slug}` - } - - async getIndex(locale?: Locale): Promise { - const url = this.buildUrl( - locale ? `/${locale}${this.apiPrefix}` : this.apiPrefix - ) - - try { - this.debug(`Fetching JSON:API index.`) - - const response = await this.fetch(url.toString(), { - // As per https://www.drupal.org/node/2984034 /jsonapi is public. - withAuth: false, - }) - - return await response.json() - } catch (error) { - this.throwError( - new Error( - `Failed to fetch JSON:API index at ${url.toString()} - ${ - error.message - }` - ) - ) - } - } - - async getEntryForResourceType( - type: string, - locale?: Locale - ): Promise { - if (this.useDefaultResourceTypeEntry) { - const [id, bundle] = type.split("--") - return ( - `${this.baseUrl}` + - (locale ? `/${locale}${this.apiPrefix}/` : `${this.apiPrefix}/`) + - `${id}/${bundle}` - ) - } - - 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 { href } = link - - // Fix for missing locale in JSON:API index. - // This fix ensures the locale is included in the resouce link. - if (locale) { - const pattern = `^\\/${locale}\\/` - const path = href.replace(this.baseUrl, "") - - /* c8 ignore next 3 */ - if (!new RegExp(pattern, "i").test(path)) { - return `${this.baseUrl}/${locale}${path}` - } - } - - return href - } - - async validateDraftUrl(searchParams: URLSearchParams): Promise { - const path = searchParams.get("path") - - this.debug(`Fetching draft url validation for ${path}.`) - - // Fetch the headless CMS to check if the provided `path` exists - let response: Response - try { - // Validate the draft url. - const validateUrl = this.buildUrl("/next/draft-url").toString() - response = await this.fetch(validateUrl, { - method: "POST", - headers: { - "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 - } - - async preview( - request: NextApiRequest, - response: NextApiResponse, - options?: Parameters[0] - ) { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { path, resourceVersion, plugin, secret, scope, ...draftData } = - request.query - const useDraftMode = options?.enable - - try { - // Always clear preview data to handle different scopes. - response.clearPreviewData() - - // Validate the preview url. - const result = await this.validateDraftUrl( - new URL(request.url, `http://${request.headers.host}`).searchParams - ) - - const validationPayload = await result.json() - const previewData = { - resourceVersion, - plugin, - ...validationPayload, - } - - if (!result.ok) { - this.debug(`Draft url validation error: ${validationPayload.message}`) - response.statusCode = result.status - return response.json(validationPayload) - } - - // Optionally turn on draft mode. - if (useDraftMode) { - response.setDraftMode(options) - } - - // Turns on preview mode and adds preview data to Next.js' static context. - response.setPreviewData(previewData) - - // Fix issue with cookie. - // See https://github.com/vercel/next.js/discussions/32238. - // See https://github.com/vercel/next.js/blob/d895a50abbc8f91726daa2d7ebc22c58f58aabbb/packages/next/server/api-utils/node.ts#L504. - const cookies = (response.getHeader("Set-Cookie") as string[]).map( - (cookie) => cookie.replace("SameSite=Lax", "SameSite=None; Secure") - ) - if (useDraftMode) { - // Adds preview data for use in app router pages. - cookies.push( - `${DRAFT_DATA_COOKIE_NAME}=${encodeURIComponent( - JSON.stringify({ path, resourceVersion, ...draftData }) - )}; Path=/; HttpOnly; SameSite=None; Secure` - ) - } - response.setHeader("Set-Cookie", cookies) - - // We can safely redirect to the path since this has been validated on the - // server. - response.writeHead(307, { Location: path }) - - this.debug(`${useDraftMode ? "Draft" : "Preview"} mode enabled.`) - - return response.end() - } catch (error) { - this.debug(`Preview failed: ${error.message}`) - return response.status(422).end() - } - } - - async previewDisable(request: NextApiRequest, response: NextApiResponse) { - // Disable both preview and draft modes. - response.clearPreviewData() - response.setDraftMode({ enable: false }) - - // Delete the draft data cookie. - const cookies = response.getHeader("Set-Cookie") as string[] - cookies.push( - `${DRAFT_DATA_COOKIE_NAME}=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; SameSite=None; Secure` - ) - response.setHeader("Set-Cookie", cookies) - - response.writeHead(307, { Location: "/" }) - response.end() - } - - async getMenu( - name: string, - options?: JsonApiOptions & JsonApiWithCacheOptions - ): 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 ${name}.`) - return JSON.parse(cached) - } - } - - const localePrefix = - options?.locale && options.locale !== options.defaultLocale - ? `/${options.locale}` - : "" - - const url = this.buildUrl( - `${localePrefix}${this.apiPrefix}/menu_items/${name}`, - options.params - ) - - this.debug(`Fetching menu items for ${name}.`) - - const response = await this.fetch(url.toString(), { - withAuth: options.withAuth, - }) - - await this.throwIfJsonApiErrors(response) - - const data = await response.json() - - const items = options.deserialize - ? this.deserialize(data) - : /* c8 ignore next */ data - - const { items: tree } = this.buildMenuTree(items) - - const menu = { - items, - tree, - } - - /* c8 ignore next 3 */ - if (options.withCache) { - await this.cache.set(options.cacheKey, JSON.stringify(menu)) - } - - return menu - } - - buildMenuTree( - links: DrupalMenuLinkContent[], - parent: DrupalMenuLinkContent["id"] = "" - ) { - if (!links?.length) { - return { - items: [], - } - } - - const children = links.filter((link) => link?.parent === parent) - - return children.length - ? { - items: children.map((link) => ({ - ...link, - ...this.buildMenuTree(links, link.id), - })), - } - : {} - } - - async getView( - name: string, - options?: JsonApiOptions - ): Promise> { - options = { - withAuth: this.withAuth, - deserialize: true, - params: {}, - ...options, - } - - const localePrefix = - options?.locale && options.locale !== options.defaultLocale - ? `/${options.locale}` - : "" - - const [viewId, displayId] = name.split("--") - - const url = this.buildUrl( - `${localePrefix}${this.apiPrefix}/views/${viewId}/${displayId}`, - options.params - ) - - this.debug(`Fetching view, ${viewId}.${displayId}.`) - - const response = await this.fetch(url.toString(), { - withAuth: options.withAuth, - }) - - await this.throwIfJsonApiErrors(response) - - const data = await response.json() - - const results = options.deserialize ? this.deserialize(data) : data - - return { - id: name, - results, - meta: data.meta, - links: data.links, - } - } - - async getSearchIndex( - name: string, - options?: JsonApiOptions - ): Promise { - options = { - withAuth: this.withAuth, - deserialize: true, - ...options, - } - - const localePrefix = - options?.locale && options.locale !== options.defaultLocale - ? `/${options.locale}` - : "" - - const url = this.buildUrl( - `${localePrefix}${this.apiPrefix}/index/${name}`, - options.params - ) - - this.debug(`Fetching search index, ${name}.`) - - const response = await this.fetch(url.toString(), { - withAuth: options.withAuth, - }) - - await this.throwIfJsonApiErrors(response) - - const json = await response.json() - - return options.deserialize ? this.deserialize(json) : json - } - - async getSearchIndexFromContext( - name: string, - context: GetStaticPropsContext, - options?: JsonApiOptions - ): Promise { - return await this.getSearchIndex(name, { - ...options, - locale: context.locale, - defaultLocale: context.defaultLocale, - }) - } - - buildUrl( - path: string, - params?: string | Record | URLSearchParams | JsonApiParams - ): URL { - const url = new URL( - path.charAt(0) === "/" ? `${this.baseUrl}${path}` : path - ) - - if (typeof params === "object" && "getQueryObject" in params) { - params = params.getQueryObject() - } - - if (params) { - // Used instead URLSearchParams for nested params. - url.search = stringify(params) - } - - return url - } - - async getAccessToken( - opts?: DrupalClientAuthClientIdSecret - ): Promise { - if (this.accessToken && this.accessTokenScope === opts?.scope) { - return this.accessToken - } - - let auth: DrupalClientAuthClientIdSecret - if (isClientIdSecretAuth(opts)) { - auth = { - url: DEFAULT_AUTH_URL, - ...opts, - } - } 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.accessTokenScope === opts?.scope && - this._token && - Date.now() < this.tokenExpiresOn - ) { - this.debug(`Using existing access token.`) - return this._token - } - - this.debug(`Fetching new access token.`) - - // Use BasicAuth to retrieve the access token. - const credentials: DrupalClientAuthUsernamePassword = { - username: auth.clientId, - password: auth.clientSecret, - } - let body = `grant_type=client_credentials` - - if (opts?.scope) { - body = `${body}&scope=${opts.scope}` - - this.debug(`Using scope: ${opts.scope}`) - } - - const response = await this.fetch(url.toString(), { - method: "POST", - headers: { - Authorization: await this.getAuthorizationHeader(credentials), - Accept: "application/json", - "Content-Type": "application/x-www-form-urlencoded", - }, - body, - }) - - await this.throwIfJsonApiErrors(response) - - const result: AccessToken = await response.json() - - this.token = result - - this.accessTokenScope = opts?.scope - - return result - } - - deserialize(body, options?) { - if (!body) return null - - return this.serializer.deserialize(body, options) - } - - private async getErrorsFromResponse(response: Response) { - const type = response.headers.get("content-type") - - if (type === "application/json") { - const error = await response.json() - return error.message - } - - // Construct error from response. - // Check for type to ensure this is a JSON:API formatted error. - // See https://jsonapi.org/format/#errors. - if (type === "application/vnd.api+json") { - const _error: JsonApiResponse = await response.json() - - if (_error?.errors?.length) { - return _error.errors - } - } - - return response.statusText - } - - private formatJsonApiErrors(errors) { - const [error] = errors - - let message = `${error.status} ${error.title}` - - if (error.detail) { - message += `\n${error.detail}` - } - - return message - } - - debug(message) { - this.isDebugEnabled && this.logger.debug(message) - } - - // Error handling. - // If throwErrors is enabled, we show errors in the Next.js overlay. - // Otherwise, we log the errors even if debugging is turned off. - // In production, errors are always logged never thrown. - private throwError(error: Error) { - if (!this.throwJsonApiErrors) { - return this.logger.error(error) - } - - throw error - } - - private async throwIfJsonApiErrors(response: Response) { - if (!response?.ok) { - const errors = await this.getErrorsFromResponse(response) - throw new JsonApiErrors(errors, response.status) - } - } - - private getAuthFromContextAndOptions( - context: GetStaticPropsContext, - options: JsonApiWithAuthOption - ) { - // If not in preview or withAuth is provided, use that. - if (!context.preview) { - // If we have provided an auth, use that. - if (typeof options?.withAuth !== "undefined") { - return options.withAuth - } - - // Otherwise we fallback to the global auth. - return this.withAuth - } - - // If no plugin is provided, return. - const plugin = context.previewData?.["plugin"] - if (!plugin) { - return null - } - - let withAuth = this._auth - - if (plugin === "simple_oauth") { - // If we are using a client id and secret auth, pass the scope. - if (isClientIdSecretAuth(withAuth) && context.previewData?.["scope"]) { - withAuth = { - ...withAuth, - scope: context.previewData?.["scope"], - } - } - } - - if (plugin === "jwt") { - const accessToken = context.previewData?.["access_token"] - - if (accessToken) { - return `Bearer ${accessToken}` - } - } - - return withAuth - } -} diff --git a/packages/next-drupal/src/deprecated.ts b/packages/next-drupal/src/deprecated.ts index 8693c514..0b1ff2e1 100644 --- a/packages/next-drupal/src/deprecated.ts +++ b/packages/next-drupal/src/deprecated.ts @@ -1,3 +1,6 @@ +import { NextDrupalPages } from "./next-drupal-pages" +export const DrupalClient = NextDrupalPages + export * from "./deprecated/get-access-token" export * from "./deprecated/get-menu" export * from "./deprecated/get-paths" diff --git a/packages/next-drupal/src/deprecated/get-menu.ts b/packages/next-drupal/src/deprecated/get-menu.ts index 17262968..d11876b6 100644 --- a/packages/next-drupal/src/deprecated/get-menu.ts +++ b/packages/next-drupal/src/deprecated/get-menu.ts @@ -1,8 +1,8 @@ import { buildHeaders, buildUrl, deserialize } from "./utils" -import type { AccessToken, DrupalMenuLinkContent } from "../types" +import type { AccessToken, DrupalMenuItem } from "../types" import type { JsonApiWithLocaleOptions } from "../types/deprecated" -export async function getMenu( +export async function getMenu( name: string, options?: { deserialize?: boolean @@ -45,8 +45,8 @@ export async function getMenu( } function buildMenuTree( - links: DrupalMenuLinkContent[], - parent: DrupalMenuLinkContent["id"] = "" + links: DrupalMenuItem[], + parent: DrupalMenuItem["id"] = "" ) { if (!links?.length) { return { diff --git a/packages/next-drupal/src/deprecated/use-menu.ts b/packages/next-drupal/src/deprecated/use-menu.ts index 25c82c2d..961b1a1d 100644 --- a/packages/next-drupal/src/deprecated/use-menu.ts +++ b/packages/next-drupal/src/deprecated/use-menu.ts @@ -1,9 +1,9 @@ import { useRouter } from "next/router" import { useEffect, useState } from "react" import { getMenu } from "./get-menu" -import type { DrupalMenuLinkContent } from "../types" +import type { DrupalMenuItem } from "../types" -export function useMenu( +export function useMenu( name: string ): { items: T[] diff --git a/packages/next-drupal/src/draft-constants.ts b/packages/next-drupal/src/draft-constants.ts new file mode 100644 index 00000000..97fe4ece --- /dev/null +++ b/packages/next-drupal/src/draft-constants.ts @@ -0,0 +1,4 @@ +export const DRAFT_DATA_COOKIE_NAME = "next_drupal_draft_data" + +// See https://vercel.com/docs/workflow-collaboration/draft-mode +export const DRAFT_MODE_COOKIE_NAME = "__prerender_bypass" diff --git a/packages/next-drupal/src/draft.ts b/packages/next-drupal/src/draft.ts index c43427c1..39da1396 100644 --- a/packages/next-drupal/src/draft.ts +++ b/packages/next-drupal/src/draft.ts @@ -1,12 +1,15 @@ import { cookies, draftMode } from "next/headers" import { redirect } from "next/navigation" -import { DRAFT_DATA_COOKIE_NAME, DRAFT_MODE_COOKIE_NAME } from "./client" +import { + DRAFT_DATA_COOKIE_NAME, + DRAFT_MODE_COOKIE_NAME, +} from "./draft-constants" import type { NextRequest } from "next/server" -import type { DrupalClient } from "./client" +import type { NextDrupal } from "./next-drupal" export async function enableDraftMode( request: NextRequest, - drupal: DrupalClient + drupal: NextDrupal ): Promise { // Validate the draft request. const response = await drupal.validateDraftUrl(request.nextUrl.searchParams) diff --git a/packages/next-drupal/src/index.ts b/packages/next-drupal/src/index.ts index 860e0b0e..d0c73444 100644 --- a/packages/next-drupal/src/index.ts +++ b/packages/next-drupal/src/index.ts @@ -1,5 +1,8 @@ -export * from "./client" +export * from "./draft-constants" export * from "./jsonapi-errors" +export * from "./next-drupal-base" +export * from "./next-drupal" +export * from "./next-drupal-pages" export type * from "./types" diff --git a/packages/next-drupal/src/jsonapi-errors.ts b/packages/next-drupal/src/jsonapi-errors.ts index e2c4c322..41017f16 100644 --- a/packages/next-drupal/src/jsonapi-errors.ts +++ b/packages/next-drupal/src/jsonapi-errors.ts @@ -17,15 +17,21 @@ export class JsonApiErrors extends Error { errors: JsonApiError[] | string statusCode: number - constructor(errors: JsonApiError[], statusCode: number) { + constructor( + errors: JsonApiError[] | string, + statusCode: number, + messagePrefix: string = "" + ) { super() this.errors = errors this.statusCode = statusCode - this.message = JsonApiErrors.formatMessage(errors) + this.message = + (messagePrefix ? `${messagePrefix} ` : "") + + JsonApiErrors.formatMessage(errors) } - private static formatMessage(errors) { + static formatMessage(errors: JsonApiError[] | string) { if (typeof errors === "string") { return errors } diff --git a/packages/next-drupal/src/menu-tree.ts b/packages/next-drupal/src/menu-tree.ts new file mode 100644 index 00000000..b315d279 --- /dev/null +++ b/packages/next-drupal/src/menu-tree.ts @@ -0,0 +1,48 @@ +import type { DrupalMenuItem, DrupalMenuItemId } from "./types" + +export class DrupalMenuTree< + T extends { + id: DrupalMenuItemId + parent: DrupalMenuItemId + items?: T[] + } = DrupalMenuItem, +> extends Array { + parentId: DrupalMenuItemId + depth: number + + constructor( + menuItems: T[], + parentId: DrupalMenuItemId = "", + depth: number = 1 + ) { + super() + + this.parentId = parentId + this.depth = depth + + if (menuItems?.length) { + this.build(menuItems, parentId) + } + } + + build(menuItems: T[], parentId: DrupalMenuItemId) { + // Find the children of the specified parent. + const children = menuItems.filter( + (menuItem) => menuItem?.parent === parentId + ) + + // Add each child to this Array. + for (const menuItem of children) { + const subtree = new DrupalMenuTree( + menuItems, + menuItem.id, + this.depth + 1 + ) + + this.push({ + ...menuItem, + items: subtree.length ? subtree : undefined, + }) + } + } +} diff --git a/packages/next-drupal/src/next-drupal-base.ts b/packages/next-drupal/src/next-drupal-base.ts new file mode 100644 index 00000000..2feb82c5 --- /dev/null +++ b/packages/next-drupal/src/next-drupal-base.ts @@ -0,0 +1,534 @@ +import { stringify } from "qs" +import { JsonApiErrors } from "./jsonapi-errors" +import { logger as defaultLogger } from "./logger" +import type { + AccessToken, + BaseUrl, + EndpointSearchParams, + FetchOptions, + JsonApiResponse, + Locale, + Logger, + NextDrupalAuth, + NextDrupalAuthAccessToken, + NextDrupalAuthClientIdSecret, + NextDrupalAuthUsernamePassword, + NextDrupalBaseOptions, + PathPrefix, +} from "./types" + +const DEFAULT_API_PREFIX = "" +const DEFAULT_FRONT_PAGE = "/home" +const DEFAULT_WITH_AUTH = false + +// From simple_oauth. +const DEFAULT_AUTH_URL = "/oauth/token" + +// See https://jsonapi.org/format/#content-negotiation. +const DEFAULT_HEADERS = { + "Content-Type": "application/json", + Accept: "application/json", +} + +export class NextDrupalBase { + accessToken?: NextDrupalBaseOptions["accessToken"] + + baseUrl: BaseUrl + + fetcher?: NextDrupalBaseOptions["fetcher"] + + frontPage: string + + isDebugEnabled: boolean + + logger: Logger + + withAuth: boolean + + private _apiPrefix: string + + private _auth?: NextDrupalAuth + + private _headers: Headers + + private _token?: AccessToken + + private _tokenExpiresOn?: number + + private _tokenRequestDetails?: NextDrupalAuthClientIdSecret + + /** + * 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: BaseUrl, options: NextDrupalBaseOptions = {}) { + 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 = defaultLogger, + 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 = logger + this.withAuth = withAuth + + this.debug("Debug mode is on.") + } + + set apiPrefix(apiPrefix: string) { + this._apiPrefix = + apiPrefix === "" || apiPrefix.startsWith("/") + ? apiPrefix + : `/${apiPrefix}` + } + + get apiPrefix() { + return this._apiPrefix + } + + set auth(auth: NextDrupalAuth) { + if (typeof auth === "object") { + const checkUsernamePassword = auth as NextDrupalAuthUsernamePassword + const checkAccessToken = auth as NextDrupalAuthAccessToken + const checkClientIdSecret = auth as NextDrupalAuthClientIdSecret + + if ( + checkUsernamePassword.username !== undefined || + checkUsernamePassword.password !== undefined + ) { + 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 !== undefined || + checkAccessToken.token_type !== undefined + ) { + 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: HeadersInit) { + this._headers = new Headers(headers) + } + + get headers() { + return this._headers + } + + set token(token: AccessToken) { + this._token = token + this._tokenExpiresOn = Date.now() + token.expires_in * 1000 + } + + get token() { + return this._token + } + + async fetch( + input: RequestInfo, + { withAuth, ...init }: FetchOptions = {} + ): Promise { + init.credentials = "include" + + // Merge the init.headers with this.headers + 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)) + } + } + + // Set Authorization header. + 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) + } + + async getAuthorizationHeader(auth: NextDrupalAuth) { + let header: string + + 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)) { + // Fetch an access token and add it to the request. getAccessToken() + // throws an error if it fails to get an access token. + 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 + } + + buildUrl(path: string, searchParams?: EndpointSearchParams): URL { + const url = new URL(path, this.baseUrl) + + const search = + // Handle DrupalJsonApiParams objects. + searchParams && + typeof searchParams === "object" && + "getQueryObject" in searchParams + ? searchParams.getQueryObject() + : searchParams + + if (search) { + // Use stringify instead of URLSearchParams for nested params. + url.search = stringify(search) + } + + return url + } + + // async so subclasses can query for endpoint discovery. + async buildEndpoint({ + locale = "", + path = "", + searchParams, + }: { + locale?: string + path?: string + searchParams?: EndpointSearchParams + } = {}): Promise { + const localeSegment = locale ? `/${locale}` : "" + + if (path && !path.startsWith("/")) { + path = `/${path}` + } + + return this.buildUrl( + `${localeSegment}${this.apiPrefix}${path}`, + searchParams + ).toString() + } + + constructPathFromSegment( + segment: string | string[], + options: { + locale?: Locale + defaultLocale?: Locale + pathPrefix?: PathPrefix + } = {} + ) { + let { pathPrefix = "" } = options + const { locale, defaultLocale } = options + + // Ensure pathPrefix starts with a "/" and does not end with a "/". + if (pathPrefix) { + if (!pathPrefix?.startsWith("/")) { + pathPrefix = `/${options.pathPrefix}` + } + if (pathPrefix.endsWith("/")) { + pathPrefix = pathPrefix.slice(0, -1) + } + } + + // If the segment is given as an array of segments, join the parts. + if (!Array.isArray(segment)) { + segment = segment ? [segment] : [] + } + segment = segment.map((part) => encodeURIComponent(part)).join("/") + + if (!segment && !pathPrefix) { + // If no pathPrefix is given and the segment is empty, then the path + // should be the homepage. + segment = this.frontPage + } + + // Ensure the segment starts with a "/" and does not end with a "/". + if (segment && !segment.startsWith("/")) { + segment = `/${segment}` + } + if (segment.endsWith("/")) { + segment = segment.slice(0, -1) + } + + return this.addLocalePrefix(`${pathPrefix}${segment}`, { + locale, + defaultLocale, + }) + } + + addLocalePrefix( + path: string, + options: { locale?: Locale; defaultLocale?: Locale } = {} + ) { + const { locale, defaultLocale } = options + + if (!path.startsWith("/")) { + path = `/${path}` + } + + let localePrefix = "" + if (locale && !path.startsWith(`/${locale}`) && locale !== defaultLocale) { + localePrefix = `/${locale}` + } + + return `${localePrefix}${path}` + } + + async getAccessToken( + clientIdSecret?: NextDrupalAuthClientIdSecret + ): Promise { + if (this.accessToken) { + return this.accessToken + } + + let auth: NextDrupalAuthClientIdSecret + 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) + + // Ensure that the unexpired token was using the same scope and client + // credentials as the current request before re-using it. + 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.`) + + // Use BasicAuth to retrieve the access token. + const clientCredentials: NextDrupalAuthUsernamePassword = { + 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: AccessToken = await response.json() + + this.token = result + + this._tokenRequestDetails = auth + + return result + } + + async validateDraftUrl(searchParams: URLSearchParams): Promise { + const path = searchParams.get("path") + + this.debug(`Fetching draft url validation for ${path}.`) + + // Fetch the headless CMS to check if the provided `path` exists + let response: Response + try { + // Validate the draft url. + 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 + } + + debug(message) { + this.isDebugEnabled && this.logger.debug(message) + } + + async throwIfJsonErrors(response: Response, messagePrefix = "") { + if (!response?.ok) { + const errors = await this.getErrorsFromResponse(response) + throw new JsonApiErrors(errors, response.status, messagePrefix) + } + } + + async getErrorsFromResponse(response: Response) { + const type = response.headers.get("content-type") + let error: JsonApiResponse | { message: string } + + if (type === "application/json") { + error = await response.json() + + if (error?.message) { + return error.message as string + } + } + + // Construct error from response. + // Check for type to ensure this is a JSON:API formatted error. + // See https://jsonapi.org/format/#errors. + else if (type === "application/vnd.api+json") { + error = (await response.json()) as JsonApiResponse + + if (error?.errors?.length) { + return error.errors + } + } + + return response.statusText + } +} + +export function isBasicAuth( + auth: NextDrupalAuth +): auth is NextDrupalAuthUsernamePassword { + return ( + (auth as NextDrupalAuthUsernamePassword)?.username !== undefined && + (auth as NextDrupalAuthUsernamePassword)?.password !== undefined + ) +} + +export function isAccessTokenAuth( + auth: NextDrupalAuth +): auth is NextDrupalAuthAccessToken { + return ( + (auth as NextDrupalAuthAccessToken)?.access_token !== undefined && + (auth as NextDrupalAuthAccessToken)?.token_type !== undefined + ) +} + +export function isClientIdSecretAuth( + auth: NextDrupalAuth +): auth is NextDrupalAuthClientIdSecret { + return ( + (auth as NextDrupalAuthClientIdSecret)?.clientId !== undefined && + (auth as NextDrupalAuthClientIdSecret)?.clientSecret !== undefined + ) +} diff --git a/packages/next-drupal/src/next-drupal-pages.ts b/packages/next-drupal/src/next-drupal-pages.ts new file mode 100644 index 00000000..fd0af89a --- /dev/null +++ b/packages/next-drupal/src/next-drupal-pages.ts @@ -0,0 +1,473 @@ +import { Jsona } from "jsona" +import { DRAFT_DATA_COOKIE_NAME } from "./draft-constants" +import { DrupalMenuTree } from "./menu-tree" +import { NextDrupal } from "./next-drupal" +import { isClientIdSecretAuth } from "./next-drupal-base" +import type { + BaseUrl, + DrupalClientOptions, + DrupalMenuItem, + DrupalMenuItemId, + DrupalPathAlias, + DrupalTranslatedPath, + JsonApiOptions, + JsonApiParams, + JsonApiResource, + JsonApiResourceWithPath, + JsonApiWithAuthOption, + JsonDeserializer, + Locale, + PathPrefix, +} from "./types" +import type { + GetStaticPathsContext, + GetStaticPathsResult, + GetStaticPropsContext, + NextApiRequest, + NextApiResponse, +} from "next" + +export class NextDrupalPages extends NextDrupal { + private serializer: DrupalClientOptions["serializer"] + + /** + * Instantiates a new NextDrupalPages. + * + * const client = new NextDrupalPages(baseUrl) + * + * @param {baseUrl} baseUrl The baseUrl of your Drupal site. Do not add the /jsonapi suffix. + * @param {options} options Options for the client. See Experiment_DrupalClientOptions. + */ + constructor(baseUrl: BaseUrl, options: DrupalClientOptions = {}) { + super(baseUrl, options) + + const { + serializer = new Jsona(), + useDefaultResourceTypeEntry = false, + useDefaultEndpoints = null, + } = options + + if (useDefaultEndpoints === null) { + this.useDefaultEndpoints = !!useDefaultResourceTypeEntry + } + + this.serializer = serializer + + this.deserializer = ( + body: Parameters[0], + options: Parameters[1] + ) => this.serializer.deserialize(body, options) + } + + async getEntryForResourceType( + resourceType: string, + locale?: Locale + ): Promise { + return this.buildEndpoint({ + locale, + resourceType, + }) + } + + /* c8 ignore next 3 */ + buildMenuTree(links: DrupalMenuItem[], parent: DrupalMenuItemId = "") { + return new DrupalMenuTree(links, parent) + } + + async getResourceFromContext( + input: string | DrupalTranslatedPath, + context: GetStaticPropsContext, + options?: { + pathPrefix?: PathPrefix + isVersionable?: boolean + } & JsonApiOptions + ): Promise { + const type = typeof input === "string" ? input : input.jsonapi.resourceName + + const previewData = context.previewData as { + resourceVersion?: string + } + + options = { + deserialize: true, + pathPrefix: "/", + withAuth: this.getAuthFromContextAndOptions(context, options), + params: {}, + ...options, + } + + const _options = { + deserialize: options.deserialize, + isVersionable: options.isVersionable, + locale: context.locale, + defaultLocale: context.defaultLocale, + withAuth: options?.withAuth, + params: options?.params, + } + + // Check if resource is versionable. + // Add support for revisions for node by default. + const isVersionable = options.isVersionable || /^node--/.test(type) + + // If the resource is versionable and no resourceVersion is supplied via params. + // Use the resourceVersion from previewData or fallback to the latest version. + if ( + isVersionable && + typeof options.params.resourceVersion === "undefined" + ) { + options.params.resourceVersion = + previewData?.resourceVersion || "rel:latest-version" + } + + if (typeof input !== "string") { + // Fix for subrequests and translation. + // TODO: Confirm if we still need this after https://www.drupal.org/i/3111456. + // Given an entity at /example with no translation. + // When we try to translate /es/example, decoupled router will properly + // translate to the untranslated version and set the locale to es. + // However a subrequests to /es/subrequests for decoupled router will fail. + /* c8 ignore next 3 */ + if (context.locale && input.entity.langcode !== context.locale) { + context.locale = input.entity.langcode + } + + // Given we already have the path info, we can skip subrequests and just make a simple + // request to the Drupal site to get the entity. + if (input.entity?.uuid) { + return await this.getResource(type, input.entity.uuid, _options) + } + } + + const path = this.getPathFromContext(context, { + pathPrefix: options?.pathPrefix, + }) + + const resource = await this.getResourceByPath(path, _options) + + // If no locale is passed, skip entity if not default_langcode. + // This happens because decoupled_router will still translate the path + // to a resource. + // TODO: Figure out if we want this behavior. + // For now this causes a bug where a non-i18n sites builds (ISR) pages for + // localized pages. + // if (!context.locale && !resource?.default_langcode) { + // return null + // } + + return resource + } + + async getResourceCollectionFromContext( + type: string, + context: GetStaticPropsContext, + options?: { + deserialize?: boolean + } & JsonApiOptions + ): Promise { + options = { + deserialize: true, + ...options, + } + + return await this.getResourceCollection(type, { + ...options, + locale: context.locale, + defaultLocale: context.defaultLocale, + withAuth: this.getAuthFromContextAndOptions(context, options), + }) + } + + async getSearchIndexFromContext( + name: string, + context: GetStaticPropsContext, + options?: JsonApiOptions + ): Promise { + return await this.getSearchIndex(name, { + ...options, + locale: context.locale, + defaultLocale: context.defaultLocale, + }) + } + + async translatePathFromContext( + context: GetStaticPropsContext, + options?: { + pathPrefix?: PathPrefix + } & JsonApiWithAuthOption + ): Promise { + options = { + pathPrefix: "/", + ...options, + } + const path = this.getPathFromContext(context, { + pathPrefix: options.pathPrefix, + }) + + return await this.translatePath(path, { + withAuth: this.getAuthFromContextAndOptions(context, options), + }) + } + + getPathFromContext( + context: GetStaticPropsContext, + options?: { + pathPrefix?: PathPrefix + } + ) { + return this.constructPathFromSegment(context.params?.slug, { + locale: context.locale, + defaultLocale: context.defaultLocale, + pathPrefix: options?.pathPrefix, + }) + } + + getPathsFromContext = this.getStaticPathsFromContext + + async getStaticPathsFromContext( + types: string | string[], + context: GetStaticPathsContext, + options?: { + params?: JsonApiParams + pathPrefix?: PathPrefix + } & JsonApiWithAuthOption + ): Promise["paths"]> { + 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, + } + + // Handle localized path aliases + if (!context.locales?.length) { + const resources = await this.getResourceCollection< + JsonApiResourceWithPath[] + >(type, { + params, + withAuth: options.withAuth, + }) + + return this.buildStaticPathsFromResources(resources, { + pathPrefix: options.pathPrefix, + }) + } + + const paths = await Promise.all( + context.locales.map(async (locale) => { + const resources = await this.getResourceCollection< + JsonApiResourceWithPath[] + >(type, { + deserialize: true, + locale, + defaultLocale: context.defaultLocale, + params, + withAuth: options.withAuth, + }) + + return this.buildStaticPathsFromResources(resources, { + locale, + pathPrefix: options.pathPrefix, + }) + }) + ) + + return paths.flat() + }) + ) + + return paths.flat() + } + + buildStaticPathsFromResources( + resources: { + path: DrupalPathAlias + }[], + options?: { + pathPrefix?: PathPrefix + locale?: Locale + } + ) { + const paths = resources + ?.flatMap((resource) => { + return resource?.path?.alias === this.frontPage + ? "/" + : resource?.path?.alias + }) + .filter(Boolean) + + return paths?.length + ? this.buildStaticPathsParamsFromPaths(paths, options) + : [] + } + + buildStaticPathsParamsFromPaths( + paths: string[], + options?: { pathPrefix?: PathPrefix; locale?: Locale } + ) { + return paths.flatMap((_path) => { + _path = _path.replace(/^\/|\/$/g, "") + + // Remove pathPrefix. + if (options?.pathPrefix && options.pathPrefix !== "/") { + // Remove leading slash from pathPrefix. + const pathPrefix = options.pathPrefix.replace(/^\//, "") + + _path = _path.replace(`${pathPrefix}/`, "") + } + + const path = { + params: { + slug: _path.split("/"), + }, + } + + if (options?.locale) { + path["locale"] = options.locale + } + + return path + }) + } + + async preview( + request: NextApiRequest, + response: NextApiResponse, + options?: Parameters[0] + ) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { path, resourceVersion, plugin, secret, scope, ...draftData } = + request.query + const useDraftMode = options?.enable + + try { + // Always clear preview data to handle different scopes. + response.clearPreviewData() + + // Validate the preview url. + const result = await this.validateDraftUrl( + new URL(request.url, `http://${request.headers.host}`).searchParams + ) + + const validationPayload = await result.json() + const previewData = { + resourceVersion, + plugin, + ...validationPayload, + } + + if (!result.ok) { + this.debug(`Draft url validation error: ${validationPayload.message}`) + response.statusCode = result.status + return response.json(validationPayload) + } + + // Optionally turn on draft mode. + if (useDraftMode) { + response.setDraftMode(options) + } + + // Turns on preview mode and adds preview data to Next.js' static context. + response.setPreviewData(previewData) + + // Fix issue with cookie. + // See https://github.com/vercel/next.js/discussions/32238. + // See https://github.com/vercel/next.js/blob/d895a50abbc8f91726daa2d7ebc22c58f58aabbb/packages/next/server/api-utils/node.ts#L504. + const cookies = (response.getHeader("Set-Cookie") as string[]).map( + (cookie) => cookie.replace("SameSite=Lax", "SameSite=None; Secure") + ) + if (useDraftMode) { + // Adds preview data for use in app router pages. + cookies.push( + `${DRAFT_DATA_COOKIE_NAME}=${encodeURIComponent( + JSON.stringify({ path, resourceVersion, ...draftData }) + )}; Path=/; HttpOnly; SameSite=None; Secure` + ) + } + response.setHeader("Set-Cookie", cookies) + + // We can safely redirect to the path since this has been validated on the + // server. + response.writeHead(307, { Location: path }) + + this.debug(`${useDraftMode ? "Draft" : "Preview"} mode enabled.`) + + return response.end() + } catch (error) { + this.debug(`Preview failed: ${error.message}`) + return response.status(422).end() + } + } + + async previewDisable(request: NextApiRequest, response: NextApiResponse) { + // Disable both preview and draft modes. + response.clearPreviewData() + response.setDraftMode({ enable: false }) + + // Delete the draft data cookie. + const cookies = response.getHeader("Set-Cookie") as string[] + cookies.push( + `${DRAFT_DATA_COOKIE_NAME}=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; SameSite=None; Secure` + ) + response.setHeader("Set-Cookie", cookies) + + response.writeHead(307, { Location: "/" }) + response.end() + } + + getAuthFromContextAndOptions( + context: GetStaticPropsContext, + options: JsonApiWithAuthOption + ) { + // If not in preview or withAuth is provided, use that. + if (!context.preview) { + // If we have provided an auth, use that. + if (typeof options?.withAuth !== "undefined") { + return options.withAuth + } + + // Otherwise we fall back to the global auth. + return this.withAuth + } + + // If no plugin is provided, return. + const plugin = context.previewData?.["plugin"] + if (!plugin) { + return null + } + + let withAuth = this.auth + + if (plugin === "simple_oauth") { + // If we are using a client id and secret auth, pass the scope. + if (isClientIdSecretAuth(withAuth) && context.previewData?.["scope"]) { + withAuth = { + ...withAuth, + scope: context.previewData?.["scope"], + } + } + } + + if (plugin === "jwt") { + const accessToken = context.previewData?.["access_token"] + + if (accessToken) { + return `Bearer ${accessToken}` + } + } + + return withAuth + } +} diff --git a/packages/next-drupal/src/next-drupal.ts b/packages/next-drupal/src/next-drupal.ts new file mode 100644 index 00000000..a5bab6d7 --- /dev/null +++ b/packages/next-drupal/src/next-drupal.ts @@ -0,0 +1,690 @@ +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, + JsonApiResource, + JsonApiResponse, + JsonApiUpdateResourceBody, + JsonApiWithAuthOption, + JsonApiWithCacheOptions, + JsonDeserializer, + Locale, + NextDrupalOptions, +} 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[0], + options: Parameters[1] + ) { + return jsonFormatter.deserialize(body, options) + } +} + +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 + } + } + + async createResource( + type: string, + body: JsonApiCreateResourceBody, + options?: JsonApiOptions + ): Promise { + 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, + }) + + await this.throwIfJsonErrors(response, "Error while creating resource: ") + + const json = await response.json() + + return options.deserialize + ? this.deserialize(json) + : /* c8 ignore next */ json + } + + async createFileResource( + type: string, + body: JsonApiCreateFileResourceBody, + options?: JsonApiOptions + ): Promise { + 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, + }) + + await this.throwIfJsonErrors( + response, + "Error while creating file resource: " + ) + + const json = await response.json() + + return options.deserialize ? this.deserialize(json) : json + } + + async updateResource( + type: string, + uuid: string, + body: JsonApiUpdateResourceBody, + options?: JsonApiOptions + ): Promise { + 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, + }) + + await this.throwIfJsonErrors(response, "Error while updating resource: ") + + const json = await response.json() + + return options.deserialize + ? this.deserialize(json) + : /* c8 ignore next */ json + } + + async deleteResource( + type: string, + uuid: string, + options?: JsonApiOptions + ): Promise { + 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, + }) + + await this.throwIfJsonErrors(response, "Error while deleting resource: ") + + return response.status === 204 + } + + async getResource( + type: string, + uuid: string, + options?: JsonApiOptions & JsonApiWithCacheOptions + ): Promise { + 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, + }) + + 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 + } + + async getResourceByPath( + path: string, + options?: { + isVersionable?: boolean + } & JsonApiOptions + ): Promise { + 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, + }) + + const json = await response.json() + + if (!json?.["resolvedResource#uri{0}"]?.body) { + if (json?.router?.body) { + const error = JSON.parse(json.router.body) + if (error?.message) { + this.logOrThrowError(new Error(error.message)) + } + } + + return null + } + + const data = JSON.parse(json["resolvedResource#uri{0}"]?.body) + + if (data.errors) { + this.logOrThrowError(new Error(JsonApiErrors.formatMessage(data.errors))) + } + + return options.deserialize ? this.deserialize(data) : data + } + + async getResourceCollection( + type: string, + options?: { + deserialize?: boolean + } & JsonApiOptions + ): Promise { + 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, + }) + + await this.throwIfJsonErrors( + response, + "Error while fetching resource collection: " + ) + + const json = await response.json() + + return options.deserialize ? this.deserialize(json) : json + } + + async translatePath( + path: string, + options?: JsonApiWithAuthOption + ): Promise { + 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, + }) + + if (!response?.ok) { + // Do not throw errors here, otherwise Next.js will catch the error and + // throw a 500. We want a 404. + return null + } + + return await response.json() + } + + async getIndex(locale?: Locale): Promise { + 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, + }) + + await this.throwIfJsonErrors( + response, + `Failed to fetch JSON:API index at ${endpoint}: ` + ) + + return await response.json() + } + + async buildEndpoint({ + locale = "", + resourceType = "", + path = "", + searchParams, + }: Parameters[0] & { + resourceType?: string + } = {}): Promise { + 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() + } + + async fetchResourceEndpoint(type: string, locale?: Locale): Promise { + 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 + } + + async getMenu( + menuName: string, + options?: JsonApiOptions & JsonApiWithCacheOptions + ): 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, + }) + + 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 + } + + async getView( + name: string, + options?: JsonApiOptions + ): Promise> { + 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, + }) + + 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, + } + } + + async getSearchIndex( + name: string, + options?: JsonApiOptions + ): Promise { + 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, + }) + + await this.throwIfJsonErrors( + response, + "Error while fetching search index: " + ) + + const json = await response.json() + + return options.deserialize ? this.deserialize(json) : json + } + + deserialize(body, options?) { + if (!body) return null + + return this.deserializer(body, options) + } + + // Error handling. + // If throwJsonApiErrors is enabled, we show errors in the Next.js overlay. + // Otherwise, we log the errors even if debugging is turned off. + // In production, errors are always logged never thrown. + logOrThrowError(error: Error) { + if (!this.throwJsonApiErrors) { + this.logger.error(error) + return + } + + throw error + } +} diff --git a/packages/next-drupal/src/types/deprecated.ts b/packages/next-drupal/src/types/deprecated.ts index e79c20c2..db47a227 100644 --- a/packages/next-drupal/src/types/deprecated.ts +++ b/packages/next-drupal/src/types/deprecated.ts @@ -1,3 +1,6 @@ -import type { JsonApiOptions } from "./index" +import type { JsonApiOptions } from "./options" +import { DrupalMenuItem } from "./drupal" export type JsonApiWithLocaleOptions = Omit + +export type DrupalMenuLinkContent = DrupalMenuItem diff --git a/packages/next-drupal/src/types/drupal.ts b/packages/next-drupal/src/types/drupal.ts index b2a77c93..93adca8a 100644 --- a/packages/next-drupal/src/types/drupal.ts +++ b/packages/next-drupal/src/types/drupal.ts @@ -37,15 +37,15 @@ export interface DrupalMedia extends JsonApiResource { name: string } -export interface DrupalMenuLinkContent { +export interface DrupalMenuItem { description: string enabled: boolean expanded: boolean - id: string + id: DrupalMenuItemId menu_name: string meta: Record options: Record - parent: string + parent: DrupalMenuItemId provider: string route: { name: string @@ -55,9 +55,11 @@ export interface DrupalMenuLinkContent { type: string url: string weight: string - items?: DrupalMenuLinkContent[] + items?: DrupalMenuItem[] } +export type DrupalMenuItemId = string + export interface DrupalNode extends JsonApiResourceWithPath { drupal_internal__nid: number drupal_internal__vid: number @@ -73,6 +75,12 @@ export interface DrupalParagraph extends JsonApiResource { drupal_internal__revision_id: number } +export type DrupalPathAlias = { + alias: string + pid: number + langcode: string +} + export interface DrupalSearchApiJsonApiResponse extends JsonApiResponse { meta: JsonApiResponse["meta"] & { facets?: DrupalSearchApiFacet[] @@ -146,9 +154,3 @@ export interface DrupalView[]> { meta: JsonApiResponse["meta"] links: JsonApiResponse["links"] } - -export type PathAlias = { - alias: string - pid: number - langcode: string -} diff --git a/packages/next-drupal/src/types/index.ts b/packages/next-drupal/src/types/index.ts index f8194aaf..4575b2ff 100644 --- a/packages/next-drupal/src/types/index.ts +++ b/packages/next-drupal/src/types/index.ts @@ -1,4 +1,6 @@ -export type * from "./client" export type * from "./drupal" +export type * from "./next-drupal-base" +export type * from "./next-drupal" +export type * from "./next-drupal-pages" export type * from "./options" export type * from "./resource" diff --git a/packages/next-drupal/src/types/client.ts b/packages/next-drupal/src/types/next-drupal-base.ts similarity index 57% rename from packages/next-drupal/src/types/client.ts rename to packages/next-drupal/src/types/next-drupal-base.ts index 7433458e..53ed464e 100644 --- a/packages/next-drupal/src/types/client.ts +++ b/packages/next-drupal/src/types/next-drupal-base.ts @@ -1,53 +1,43 @@ -export type DrupalClientOptions = { - /** - * Set the JSON:API prefix. - * - * * **Default value**: `/jsonapi` - * * **Required**: *No* - * - * [Documentation](https://next-drupal.org/docs/client/configuration#apiprefix) - */ - apiPrefix?: string +import type { JsonApiParams } from "./options" +export type NextDrupalBaseOptions = { /** - * Set debug to true to enable debug messages. + * A long-lived access token you can set for the client. * - * * **Default value**: `false` + * * **Default value**: `null` * * **Required**: *No* * - * [Documentation](https://next-drupal.org/docs/client/configuration#debug) + * [Documentation](https://next-drupal.org/docs/client/configuration#accesstoken) */ - debug?: boolean + accessToken?: AccessToken /** - * Set the default frontPage. + * Set the JSON:API prefix. * - * * **Default value**: `/home` + * * **Default value**: `/jsonapi` * * **Required**: *No* * - * [Documentation](https://next-drupal.org/docs/client/configuration#frontpage) + * [Documentation](https://next-drupal.org/docs/client/configuration#apiprefix) */ - frontPage?: string + apiPrefix?: string /** - * Set custom headers for the fetcher. - * - * * **Default value**: { "Content-Type": "application/vnd.api+json", Accept: "application/vnd.api+json" } - * * **Required**: *No* + * Override the default auth. You can use this to implement your own authentication mechanism. * - * [Documentation](https://next-drupal.org/docs/client/configuration#headers) + * [Documentation](https://next-drupal.org/docs/client/configuration#auth) */ - headers?: HeadersInit + auth?: NextDrupalAuth /** - * Override the default data serializer. You can use this to add your own JSON:API data deserializer. + * Set debug to true to enable debug messages. * - * * **Default value**: `jsona` + * * **Default value**: `false` * * **Required**: *No* * - * [Documentation](https://next-drupal.org/docs/client/configuration#serializer) + * [Documentation](https://next-drupal.org/docs/client/configuration#debug) */ - serializer?: Serializer + debug?: boolean + /** * Override the default fetcher. Use this to add your own fetcher ex. axios. * @@ -59,24 +49,24 @@ export type DrupalClientOptions = { fetcher?: Fetcher /** - * Override the default cache. + * Set the default frontPage. * - * * **Default value**: `node-cache` + * * **Default value**: `/home` * * **Required**: *No* * - * [Documentation](https://next-drupal.org/docs/client/configuration#cache) + * [Documentation](https://next-drupal.org/docs/client/configuration#frontpage) */ - cache?: DataCache + frontPage?: string /** - * If set to true, JSON:API errors are thrown in non-production environments. The errors are shown in the Next.js overlay. + * Set custom headers for the fetcher. * - * **Default value**: `true` - * **Required**: *No* + * * **Default value**: { "Content-Type": "application/vnd.api+json", Accept: "application/vnd.api+json" } + * * **Required**: *No* * - * [Documentation](https://next-drupal.org/docs/client/configuration#throwjsonapierrors) + * [Documentation](https://next-drupal.org/docs/client/configuration#headers) */ - throwJsonApiErrors?: boolean + headers?: HeadersInit /** * Override the default logger. You can use this to send logs to a third-party service. @@ -88,13 +78,6 @@ export type DrupalClientOptions = { */ logger?: Logger - /** - * Override the default auth. You can use this to implement your own authentication mechanism. - * - * [Documentation](https://next-drupal.org/docs/client/configuration#auth) - */ - auth?: DrupalClientAuth - /** * Set whether the client should use authenticated requests by default. * @@ -104,68 +87,37 @@ export type DrupalClientOptions = { * [Documentation](https://next-drupal.org/docs/client/configuration#withauth) */ withAuth?: boolean - - /** - * By default, the client will make a request to JSON:API to retrieve the index. You can turn this off and use the default entry point from the resource name. - * - * * **Default value**: `false` - * * **Required**: *No* - * - * [Documentation](https://next-drupal.org/docs/client/configuration#auth) - */ - useDefaultResourceTypeEntry?: boolean - - /** - * A long-lived access token you can set for the client. - * - * * **Default value**: `null` - * * **Required**: *No* - * - * [Documentation](https://next-drupal.org/docs/client/configuration#accesstoken) - */ - accessToken?: AccessToken - - /** - * The scope used for the current access token. - */ - accessTokenScope?: string } -export type DrupalClientAuth = - | DrupalClientAuthClientIdSecret - | DrupalClientAuthUsernamePassword - | DrupalClientAuthAccessToken +export type NextDrupalAuth = + | NextDrupalAuthAccessToken + | NextDrupalAuthClientIdSecret + | NextDrupalAuthUsernamePassword | (() => string) | string -export interface DrupalClientAuthUsernamePassword { - username: string - password: string -} +export type NextDrupalAuthAccessToken = AccessToken -export interface DrupalClientAuthClientIdSecret { +export interface NextDrupalAuthClientIdSecret { clientId: string clientSecret: string url?: string scope?: string } -export type DrupalClientAuthAccessToken = AccessToken +export interface NextDrupalAuthUsernamePassword { + username: string + password: string +} -export type AccessToken = { +export interface AccessToken { token_type: string - expires_in: number access_token: string + expires_in: number refresh_token?: string } -export interface DataCache { - get(key): Promise - - set(key, value, ttl?: number): Promise - - del?(keys): Promise -} +export type AccessTokenScope = string export type Fetcher = WindowOrWorkerGlobalScope["fetch"] @@ -179,9 +131,8 @@ export interface Logger { error(message): void } -export interface Serializer { - deserialize( - body: Record, - options?: Record - ): unknown -} +export type EndpointSearchParams = + | string + | Record + | URLSearchParams + | JsonApiParams diff --git a/packages/next-drupal/src/types/next-drupal-pages.ts b/packages/next-drupal/src/types/next-drupal-pages.ts new file mode 100644 index 00000000..f7789b98 --- /dev/null +++ b/packages/next-drupal/src/types/next-drupal-pages.ts @@ -0,0 +1,41 @@ +import type { + NextDrupalAuth, + NextDrupalAuthClientIdSecret, + NextDrupalAuthUsernamePassword, + NextDrupalAuthAccessToken, +} from "./next-drupal-base" +import type { JsonDeserializer, NextDrupalOptions } from "./next-drupal" + +export type DrupalClientOptions = NextDrupalOptions & { + /** + * Override the default data serializer. You can use this to add your own JSON:API data deserializer. + * + * * **Default value**: `jsona` + * * **Required**: *No* + * + * [Documentation](https://next-drupal.org/docs/client/configuration#serializer) + */ + serializer?: Serializer + + /** + * By default, the client will make a request to JSON:API to retrieve the endpoint url. You can turn this off and use the default endpoint based on the resource name. + * + * * **Default value**: `false` + * * **Required**: *No* + * + * [Documentation](https://next-drupal.org/docs/configuration#usedefaultresourcetypeentry) + */ + useDefaultResourceTypeEntry?: boolean +} + +export type DrupalClientAuth = NextDrupalAuth + +export type DrupalClientAuthUsernamePassword = NextDrupalAuthUsernamePassword + +export type DrupalClientAuthClientIdSecret = NextDrupalAuthClientIdSecret + +export type DrupalClientAuthAccessToken = NextDrupalAuthAccessToken + +export interface Serializer { + deserialize: JsonDeserializer +} diff --git a/packages/next-drupal/src/types/next-drupal.ts b/packages/next-drupal/src/types/next-drupal.ts new file mode 100644 index 00000000..37407e10 --- /dev/null +++ b/packages/next-drupal/src/types/next-drupal.ts @@ -0,0 +1,57 @@ +import type { TJsonaModel } from "jsona/lib/JsonaTypes" +import type { NextDrupalBaseOptions } from "./next-drupal-base" + +export type NextDrupalOptions = NextDrupalBaseOptions & { + /** + * Override the default cache. + * + * * **Default value**: `node-cache` + * * **Required**: *No* + * + * [Documentation](https://next-drupal.org/docs/client/configuration#cache) + */ + cache?: DataCache + + /** + * Override the default data deserializer. You can use this to add your own JSON:API data deserializer. + * + * * **Default value**: `(new jsona()).deserialize` + * * **Required**: *No* + * + * [Documentation](https://next-drupal.org/docs/client/configuration#deserializer) + */ + deserializer?: JsonDeserializer + + /** + * If set to true, JSON:API errors are thrown in non-production environments. The errors are shown in the Next.js overlay. + * + * **Default value**: `true` + * **Required**: *No* + * + * [Documentation](https://next-drupal.org/docs/client/configuration#throwjsonapierrors) + */ + throwJsonApiErrors?: boolean + + /** + * By default, the resource endpoint will be based on the resource name. If you turn this off, a JSON:API request will retrieve the resource's endpoint url. + * + * * **Default value**: `true` + * * **Required**: *No* + * + * [Documentation](https://next-drupal.org/docs/client/configuration#usedefaultendpoints) + */ + useDefaultEndpoints?: boolean +} + +export type JsonDeserializer = ( + body: Record, + options?: Record +) => TJsonaModel | TJsonaModel[] + +export interface DataCache { + get(key): Promise + + set(key, value, ttl?: number): Promise + + del?(keys): Promise +} diff --git a/packages/next-drupal/src/types/options.ts b/packages/next-drupal/src/types/options.ts index c03e0d72..14c8750c 100644 --- a/packages/next-drupal/src/types/options.ts +++ b/packages/next-drupal/src/types/options.ts @@ -1,4 +1,4 @@ -import type { DrupalClientAuth } from "./client" +import type { NextDrupalAuth } from "./next-drupal-base" export type BaseUrl = string @@ -7,7 +7,7 @@ export type Locale = string export type PathPrefix = string export interface FetchOptions extends RequestInit { - withAuth?: boolean | DrupalClientAuth + withAuth?: boolean | NextDrupalAuth } export type JsonApiOptions = { @@ -26,7 +26,7 @@ export type JsonApiOptions = { ) export type JsonApiWithAuthOption = { - withAuth?: boolean | DrupalClientAuth + withAuth?: boolean | NextDrupalAuth } export type JsonApiWithCacheOptions = { diff --git a/packages/next-drupal/src/types/resource.ts b/packages/next-drupal/src/types/resource.ts index 99e5f28b..4265c4e3 100644 --- a/packages/next-drupal/src/types/resource.ts +++ b/packages/next-drupal/src/types/resource.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { JsonApiError, JsonApiLinks } from "../jsonapi-errors" -import type { PathAlias } from "./drupal" +import type { DrupalPathAlias } from "./drupal" // TODO: any...ugh. export interface JsonApiResponse extends Record { @@ -63,5 +63,5 @@ export interface JsonApiResource extends Record { } export interface JsonApiResourceWithPath extends JsonApiResource { - path: PathAlias + path: DrupalPathAlias } diff --git a/packages/next-drupal/tests/DrupalClient/basic-methods.test.ts b/packages/next-drupal/tests/DrupalClient/basic-methods.test.ts deleted file mode 100644 index 7da4c9fa..00000000 --- a/packages/next-drupal/tests/DrupalClient/basic-methods.test.ts +++ /dev/null @@ -1,401 +0,0 @@ -import { afterEach, describe, expect, jest, test } from "@jest/globals" -import { DrupalClient, JsonApiErrors } from "../../src" -import { BASE_URL, mockLogger, spyOnFetch, spyOnFetchOnce } from "../utils" -import type { DrupalNode, JsonApiError, Serializer } from "../../src" - -jest.setTimeout(10000) - -afterEach(() => { - jest.restoreAllMocks() -}) - -describe("buildUrl()", () => { - const client = new DrupalClient(BASE_URL) - - test("builds a url", () => { - expect(client.buildUrl("http://example.com").toString()).toEqual( - "http://example.com/" - ) - }) - - test("builds a relative url", () => { - expect(client.buildUrl("/foo").toString()).toEqual(`${BASE_URL}/foo`) - }) - - test("builds a url with params", () => { - expect(client.buildUrl("/foo", { bar: "baz" }).toString()).toEqual( - `${BASE_URL}/foo?bar=baz` - ) - - expect( - client - .buildUrl("/jsonapi/node/article", { - sort: "-created", - "fields[node--article]": "title,path", - }) - .toString() - ).toEqual( - `${BASE_URL}/jsonapi/node/article?sort=-created&fields%5Bnode--article%5D=title%2Cpath` - ) - }) - - test("builds a url from object (DrupalJsonApiParams)", () => { - const params = { - getQueryObject: () => ({ - sort: "-created", - "fields[node--article]": "title,path", - }), - } - - expect(client.buildUrl("/jsonapi/node/article", params).toString()).toEqual( - `${BASE_URL}/jsonapi/node/article?sort=-created&fields%5Bnode--article%5D=title%2Cpath` - ) - }) -}) - -describe("debug()", () => { - test("does not print messages by default", () => { - const logger = mockLogger() - const client = new DrupalClient(BASE_URL, { logger }) - const message = "Example message" - client.debug(message) - expect(logger.debug).not.toHaveBeenCalled() - }) - - test("prints messages when debugging on", () => { - const logger = mockLogger() - const client = new DrupalClient(BASE_URL, { logger, debug: true }) - const message = "Example message" - client.debug(message) - expect(logger.debug).toHaveBeenCalledWith("Debug mode is on.") - expect(logger.debug).toHaveBeenCalledWith(message) - }) -}) - -describe("deserialize()", () => { - test("deserializes JSON:API resource", async () => { - const client = new DrupalClient(BASE_URL) - const url = client.buildUrl( - "/jsonapi/node/article/52837ad0-f218-46bd-a106-5710336b7053", - { - include: "field_tags", - } - ) - - const response = await client.fetch(url.toString()) - const json = await response.json() - const article = client.deserialize(json) as DrupalNode - - expect(article).toMatchSnapshot() - expect(article.id).toEqual("52837ad0-f218-46bd-a106-5710336b7053") - expect(article.field_tags).toHaveLength(3) - }) - - test("deserializes JSON:API collection", async () => { - const client = new DrupalClient(BASE_URL) - const url = client.buildUrl("/jsonapi/node/article", { - getQueryObject: () => ({ - "fields[node--article]": "title", - }), - }) - - const response = await client.fetch(url.toString()) - const json = await response.json() - const articles = client.deserialize(json) as DrupalNode[] - - expect(articles).toMatchSnapshot() - }) - - test("allows for custom data serializer", async () => { - const serializer: Serializer = { - deserialize: ( - body: { data: { id: string; attributes: { title: string } } }, - options: { pathPrefix: string } - ) => { - return { - id: body.data.id, - title: `${options.pathPrefix}: ${body.data.attributes.title}`, - } - }, - } - const client = new DrupalClient(BASE_URL, { - serializer, - }) - const url = client.buildUrl( - "/jsonapi/node/article/52837ad0-f218-46bd-a106-5710336b7053" - ) - - const response = await client.fetch(url.toString()) - const json = await response.json() - const article = client.deserialize(json, { - pathPrefix: "TITLE", - }) as DrupalNode - - expect(article).toMatchSnapshot() - expect(article.id).toEqual("52837ad0-f218-46bd-a106-5710336b7053") - expect(article.title).toEqual(`TITLE: ${json.data.attributes.title}`) - }) - - test("returns null if no body", () => { - const client = new DrupalClient(BASE_URL) - expect(client.deserialize("")).toBe(null) - }) -}) - -describe("formatJsonApiErrors()", () => { - const errors: JsonApiError[] = [ - { - status: "404", - title: "First error", - }, - { - status: "500", - title: "Second error", - detail: "is ignored", - }, - ] - const client = new DrupalClient(BASE_URL) - - test("formats the first error in the array", () => { - expect(client.formatJsonApiErrors(errors)).toBe("404 First error") - }) - - test("includes the optional error detail", () => { - expect( - client.formatJsonApiErrors([ - { - ...errors[0], - detail: "Detail is included.", - }, - errors[1], - ]) - ).toBe("404 First error\nDetail is included.") - }) -}) - -describe("getErrorsFromResponse()", () => { - const client = new DrupalClient(BASE_URL) - - test("returns application/json error message", async () => { - const message = "An error occurred." - const response = new Response(JSON.stringify({ message }), { - status: 403, - headers: { - "content-type": "application/json", - }, - }) - - expect(await client.getErrorsFromResponse(response)).toBe(message) - }) - - test("returns application/vnd.api+json errors", async () => { - const payload = { - errors: [ - { - status: "404", - title: "Not found", - detail: "Oops.", - }, - { - status: "418", - title: "I am a teapot", - detail: "Even RFCs have easter eggs.", - }, - ] as JsonApiError[], - } - const response = new Response(JSON.stringify(payload), { - status: 403, - headers: { - "content-type": "application/vnd.api+json", - }, - }) - - expect(await client.getErrorsFromResponse(response)).toMatchObject( - payload.errors - ) - }) - - test("returns the response status text if the application/vnd.api+json errors cannot be found", async () => { - const payload = { - contains: 'no "errors" entry', - } - const response = new Response(JSON.stringify(payload), { - status: 418, - statusText: "I'm a Teapot", - headers: { - "content-type": "application/vnd.api+json", - }, - }) - - expect(await client.getErrorsFromResponse(response)).toBe("I'm a Teapot") - }) - - test("returns the response status text if no errors can be found", async () => { - const response = new Response(JSON.stringify({}), { - status: 403, - statusText: "Forbidden", - }) - - expect(await client.getErrorsFromResponse(response)).toBe("Forbidden") - }) -}) - -describe("throwError()", () => { - test("throws the error", () => { - const client = new DrupalClient(BASE_URL) - expect(() => { - client.throwError(new Error("Example error")) - }).toThrow("Example error") - }) - - test("logs the error when throwJsonApiErrors is false", () => { - const logger = mockLogger() - const client = new DrupalClient(BASE_URL, { - throwJsonApiErrors: false, - logger, - }) - expect(() => { - client.throwError(new Error("Example error")) - }).not.toThrow() - expect(logger.error).toHaveBeenCalledWith(new Error("Example error")) - }) -}) - -describe("throwIfJsonApiErrors()", () => { - const client = new DrupalClient(BASE_URL) - - test("does not throw if response is ok", async () => { - expect.assertions(1) - - const response = new Response(JSON.stringify({})) - - await expect(client.throwIfJsonApiErrors(response)).resolves.toBe(undefined) - }) - - test("throws a JsonApiErrors object", async () => { - expect.assertions(1) - - const payload = { - errors: [ - { - status: "404", - title: "Not found", - detail: "Oops.", - }, - { - status: "418", - title: "I am a teapot", - detail: "Even RFCs have easter eggs.", - }, - ] as JsonApiError[], - } - const status = 403 - const response = new Response(JSON.stringify(payload), { - status, - headers: { - "content-type": "application/vnd.api+json", - }, - }) - - const expectedError = new JsonApiErrors(payload.errors, status) - await expect(client.throwIfJsonApiErrors(response)).rejects.toEqual( - expectedError - ) - }) -}) - -describe("validateDraftUrl()", () => { - test("outputs debug messages", async () => { - const logger = mockLogger() - const client = new DrupalClient(BASE_URL, { - debug: true, - logger, - }) - const path = "/example" - const searchParams = new URLSearchParams({ - path, - }) - - const testPayload = { test: "resolved" } - spyOnFetchOnce({ - responseBody: testPayload, - }) - spyOnFetchOnce({ - responseBody: { - message: "fail", - }, - status: 404, - }) - - let response = await client.validateDraftUrl(searchParams) - expect(response.status).toBe(200) - expect(logger.debug).toHaveBeenCalledWith("Debug mode is on.") - expect(logger.debug).toHaveBeenCalledWith( - `Fetching draft url validation for ${path}.` - ) - expect(logger.debug).toHaveBeenCalledWith(`Validated path, ${path}`) - - response = await client.validateDraftUrl(searchParams) - expect(response.status).toBe(404) - expect(logger.debug).toHaveBeenCalledWith( - `Could not validate path, ${path}` - ) - }) - - test("calls draft-url endpoint", async () => { - const client = new DrupalClient(BASE_URL) - const searchParams = new URLSearchParams({ - path: "/example", - }) - - const testPayload = { test: "resolved" } - const fetchSpy = spyOnFetch({ responseBody: testPayload }) - - await client.validateDraftUrl(searchParams) - - expect(fetchSpy).toHaveBeenNthCalledWith( - 1, - `${BASE_URL}/next/draft-url`, - expect.objectContaining({ - method: "POST", - headers: { - Accept: "application/vnd.api+json", - "Content-Type": "application/json", - }, - body: JSON.stringify(Object.fromEntries(searchParams.entries())), - }) - ) - }) - - test("returns a response object on success", async () => { - const client = new DrupalClient(BASE_URL) - const searchParams = new URLSearchParams({ - path: "/example", - }) - - const testPayload = { test: "resolved" } - spyOnFetch({ responseBody: testPayload }) - - const response = await client.validateDraftUrl(searchParams) - - expect(response.ok).toBe(true) - expect(response.status).toBe(200) - expect(await response.json()).toMatchObject(testPayload) - }) - - test("returns a response if fetch throws", async () => { - const client = new DrupalClient(BASE_URL) - const searchParams = new URLSearchParams({ - path: "/example", - }) - - const message = "random fetch error" - spyOnFetch({ throwErrorMessage: message }) - - const response = await client.validateDraftUrl(searchParams) - - expect(response.ok).toBe(false) - expect(response.status).toBe(401) - expect(await response.json()).toMatchObject({ message }) - }) -}) diff --git a/packages/next-drupal/tests/DrupalClient/constructor.test.ts b/packages/next-drupal/tests/DrupalClient/constructor.test.ts deleted file mode 100644 index d1e02243..00000000 --- a/packages/next-drupal/tests/DrupalClient/constructor.test.ts +++ /dev/null @@ -1,316 +0,0 @@ -import { afterEach, describe, expect, jest, test } from "@jest/globals" -import { Jsona } from "jsona" -import { DrupalClient } from "../../src" -import { DEBUG_MESSAGE_PREFIX, logger as defaultLogger } from "../../src/logger" -import { BASE_URL } from "../utils" -import type { DrupalClientAuth, Logger } from "../../src" - -afterEach(() => { - jest.restoreAllMocks() -}) - -describe("baseUrl parameter", () => { - const env = process.env - - beforeEach(() => { - jest.resetModules() - process.env = { ...env } - }) - - afterEach(() => { - process.env = env - }) - - test("throws error given an invalid baseUrl", () => { - // @ts-ignore - expect(() => new DrupalClient()).toThrow("The 'baseUrl' param is required.") - - // @ts-ignore - expect(() => new DrupalClient({})).toThrow( - "The 'baseUrl' param is required." - ) - }) - - test("turns throwJsonApiErrors off in production", () => { - process.env = { - ...process.env, - NODE_ENV: "production", - } - - const client = new DrupalClient(BASE_URL) - expect(client.throwJsonApiErrors).toBe(false) - }) - - test("announces debug mode when turned on", () => { - const consoleSpy = jest.spyOn(console, "debug").mockImplementation(() => { - // - }) - - new DrupalClient(BASE_URL, { - debug: true, - }) - - expect(consoleSpy).toHaveBeenCalledWith( - DEBUG_MESSAGE_PREFIX, - "Debug mode is on." - ) - }) - - test("returns a DrupalClient", () => { - expect(new DrupalClient(BASE_URL)).toBeInstanceOf(DrupalClient) - }) -}) - -describe("options parameter", () => { - describe("accessToken", () => { - test("defaults to `undefined`", () => { - const client = new DrupalClient(BASE_URL) - expect(client.accessToken).toBe(undefined) - }) - - test("sets the accessToken", async () => { - const accessToken = { - token_type: "Bearer", - expires_in: 300, - access_token: - "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImp0aSI6ImVlNDkyOTI4ZTZjNj", - } - - const client = new DrupalClient(BASE_URL, { - accessToken, - }) - - expect(client.accessToken).toEqual(accessToken) - }) - }) - - describe("apiPrefix", () => { - test('defaults to "/jsonapi"', () => { - const client = new DrupalClient(BASE_URL) - expect(client.apiPrefix).toBe("/jsonapi") - }) - - test("sets the apiPrefix", () => { - const customEndPoint = "/customapi" - const client = new DrupalClient(BASE_URL, { - apiPrefix: customEndPoint, - }) - expect(client.apiPrefix).toBe(customEndPoint) - }) - }) - - describe("auth", () => { - test("defaults to `undefined`", () => { - const client = new DrupalClient(BASE_URL) - expect(client.auth).toBe(undefined) - }) - - test("sets the auth credentials", () => { - const auth: DrupalClientAuth = { - username: "example", - password: "pw", - } - const client = new DrupalClient(BASE_URL, { - auth, - }) - expect(client._auth).toMatchObject({ - ...auth, - }) - }) - }) - - describe("cache", () => { - test("defaults to `null`", () => { - const client = new DrupalClient(BASE_URL) - expect(client.cache).toBe(null) - }) - - test("sets the cache storage", () => { - const customCache: DrupalClient["cache"] = { - async get(key) { - // - }, - async set(key, value, ttl?: number) { - // - }, - } - const client = new DrupalClient(BASE_URL, { - cache: customCache, - }) - expect(client.cache).toBe(customCache) - }) - }) - - describe("debug", () => { - test("defaults to `false`", () => { - const consoleSpy = jest.spyOn(console, "debug").mockImplementation(() => { - // - }) - - new DrupalClient(BASE_URL) - - expect(consoleSpy).toBeCalledTimes(0) - }) - - test("turns on debug mode", () => { - const consoleSpy = jest.spyOn(console, "debug").mockImplementation(() => { - // - }) - - new DrupalClient(BASE_URL, { debug: true }) - - expect(consoleSpy).toBeCalledTimes(1) - }) - }) - - describe("fetcher", () => { - test("defaults to `undefined`", () => { - const client = new DrupalClient(BASE_URL) - expect(client.fetcher).toBe(undefined) - }) - - test("sets up a custom fetcher", () => { - const customFetcher: DrupalClient["fetcher"] = async () => { - // - } - const client = new DrupalClient(BASE_URL, { - fetcher: customFetcher, - }) - expect(client.fetcher).toBe(customFetcher) - }) - }) - - describe("frontPage", () => { - test('defaults to "/home"', () => { - const client = new DrupalClient(BASE_URL) - expect(client.frontPage).toBe("/home") - }) - - test("sets up a custom frontPage", () => { - const customFrontPage = "/front" - - const client = new DrupalClient(BASE_URL, { - frontPage: customFrontPage, - }) - expect(client.frontPage).toBe(customFrontPage) - }) - }) - - describe("headers", () => { - test("defaults to `Content-Type`/`Accept`", () => { - const client = new DrupalClient(BASE_URL) - expect(client._headers).toMatchObject({ - "Content-Type": "application/vnd.api+json", - Accept: "application/vnd.api+json", - }) - }) - - test("sets custom headers", () => { - const customHeaders = { - CustomContentType: "application/json", - CustomAccept: "application/json", - } - - const client = new DrupalClient(BASE_URL, { - headers: customHeaders, - }) - expect(client._headers).toMatchObject(customHeaders) - }) - }) - - describe("logger", () => { - test("defaults to `console`-based `Logger`", () => { - const client = new DrupalClient(BASE_URL) - expect(client.logger).toBe(defaultLogger) - }) - - test("sets up a custom logger", () => { - const customLogger: Logger = { - log: () => { - // - }, - debug: () => { - // - }, - warn: () => { - // - }, - error: () => { - // - }, - } - - const client = new DrupalClient(BASE_URL, { - logger: customLogger, - }) - expect(client.logger).toBe(customLogger) - }) - }) - - describe("serializer", () => { - test("defaults to `new Jsona()`", () => { - const client = new DrupalClient(BASE_URL) - expect(client.serializer).toBeInstanceOf(Jsona) - }) - - test("sets up a custom serializer", () => { - const customSerializer: DrupalClient["serializer"] = { - deserialize( - body: Record, - options?: Record - ): unknown { - return { - deserialized: true, - } - }, - } - - const client = new DrupalClient(BASE_URL, { - serializer: customSerializer, - }) - expect(client.serializer).toBe(customSerializer) - }) - }) - - describe("throwJsonApiErrors", () => { - test("defaults to `true`", () => { - const client = new DrupalClient(BASE_URL) - expect(client.throwJsonApiErrors).toBe(true) - }) - - test("can be set to `false`", () => { - const client = new DrupalClient(BASE_URL, { - throwJsonApiErrors: false, - }) - expect(client.throwJsonApiErrors).toBe(false) - }) - }) - - describe("useDefaultResourceTypeEntry", () => { - test("defaults to `false`", () => { - const client = new DrupalClient(BASE_URL) - expect(client.useDefaultResourceTypeEntry).toBe(false) - }) - - test("can be set to `true`", () => { - const client = new DrupalClient(BASE_URL, { - useDefaultResourceTypeEntry: true, - }) - expect(client.useDefaultResourceTypeEntry).toBe(true) - }) - }) - - describe("withAuth", () => { - test("defaults to `false`", () => { - const client = new DrupalClient(BASE_URL) - expect(client.withAuth).toBe(false) - }) - - test("can be set to `true`", () => { - const client = new DrupalClient(BASE_URL, { - withAuth: true, - }) - expect(client.withAuth).toBe(true) - }) - }) -}) diff --git a/packages/next-drupal/tests/DrupalClient/fetch-methods.test.ts b/packages/next-drupal/tests/DrupalClient/fetch-methods.test.ts deleted file mode 100644 index 6f97b044..00000000 --- a/packages/next-drupal/tests/DrupalClient/fetch-methods.test.ts +++ /dev/null @@ -1,411 +0,0 @@ -import { afterEach, describe, expect, jest, test } from "@jest/globals" -import { DrupalClient } from "../../src" -import { - BASE_URL, - mockLogger, - mocks, - spyOnFetch, - spyOnFetchOnce, -} from "../utils" -import type { AccessToken, DrupalClientAuth } from "../../src" - -afterEach(() => { - jest.restoreAllMocks() -}) - -describe("fetch()", () => { - const defaultInit = { - credentials: "include", - headers: { - "Content-Type": "application/vnd.api+json", - Accept: "application/vnd.api+json", - }, - } - const mockUrl = "https://example.com/mock-url" - const authHeader = mocks.auth.customAuthenticationHeader - - test("uses global fetch by default", async () => { - const logger = mockLogger() - const client = new DrupalClient(BASE_URL, { - debug: true, - logger, - }) - const mockResponseBody = { success: true } - const mockUrl = "https://example.com/mock-url" - const mockInit = { - priority: "high", - } - const fetchSpy = spyOnFetch({ responseBody: mockResponseBody }) - - const response = await client.fetch(mockUrl, mockInit) - - expect(fetchSpy).toBeCalledTimes(1) - expect(fetchSpy).toBeCalledWith( - mockUrl, - expect.objectContaining({ - ...defaultInit, - ...mockInit, - }) - ) - expect(response.headers.get("content-type")).toEqual( - "application/vnd.api+json" - ) - expect(await response.json()).toMatchObject(mockResponseBody) - expect(logger.debug).toHaveBeenLastCalledWith( - `Using default fetch, fetching: ${mockUrl}` - ) - }) - - test("allows for custom fetcher", async () => { - const logger = mockLogger() - const customFetch = jest.fn() - - const client = new DrupalClient(BASE_URL, { - fetcher: customFetch, - debug: true, - logger, - }) - const mockUrl = "https://example.com/mock-url" - const mockInit = { - priority: "high", - } - - await client.fetch(mockUrl, mockInit) - - expect(customFetch).toBeCalledTimes(1) - expect(customFetch).toHaveBeenCalledWith( - mockUrl, - expect.objectContaining({ - ...mockInit, - ...defaultInit, - }) - ) - expect(logger.debug).toHaveBeenLastCalledWith( - `Using custom fetcher, fetching: ${mockUrl}` - ) - }) - - test("allows setting custom headers", async () => { - const customFetch = jest.fn() - const constructorHeaders = { - constructor: "header", - Accept: "application/set-from-constructor", - } - const paramHeaders = { - params: "header", - Accept: "application/set-from-params", - } - const client = new DrupalClient(BASE_URL, { - fetcher: customFetch, - headers: constructorHeaders, - }) - - const url = "http://example.com" - - await client.fetch(url, { - headers: paramHeaders, - }) - - expect(customFetch).toHaveBeenLastCalledWith( - url, - expect.objectContaining({ - ...defaultInit, - headers: { - ...constructorHeaders, - ...paramHeaders, - }, - }) - ) - }) - - test("does not add Authorization header by default", async () => { - const fetcher = jest.fn() - const client = new DrupalClient(BASE_URL, { - auth: authHeader, - fetcher, - }) - - await client.fetch(mockUrl) - - expect(fetcher).toHaveBeenLastCalledWith( - mockUrl, - expect.objectContaining({ - headers: expect.not.objectContaining({ - Authorization: expect.anything(), - }), - }) - ) - }) - - test("optionally adds Authorization header from constructor", async () => { - const fetcher = jest.fn() - const client = new DrupalClient(BASE_URL, { - auth: authHeader, - fetcher, - }) - - await client.fetch(mockUrl, { withAuth: true }) - - expect(fetcher).toHaveBeenLastCalledWith( - mockUrl, - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: authHeader, - }), - }) - ) - }) - - test("optionally adds Authorization header from init", async () => { - const fetcher = jest.fn() - const client = new DrupalClient(BASE_URL, { - fetcher, - }) - - await client.fetch(mockUrl, { withAuth: authHeader }) - - expect(fetcher).toHaveBeenLastCalledWith( - mockUrl, - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: authHeader, - }), - }) - ) - }) -}) - -describe("getAccessToken()", () => { - const accessToken = mocks.auth.accessToken - const clientIdSecret = mocks.auth.clientIdSecret - - test("uses the long-lived access token from constructor", async () => { - const longLivedAccessToken: AccessToken = { - ...accessToken, - expires_in: 360000, - } - const client = new DrupalClient(BASE_URL, { - accessToken: longLivedAccessToken, - }) - const fetchSpy = spyOnFetch({ - responseBody: { - ...accessToken, - access_token: "not-used", - }, - }) - - const token = await client.getAccessToken({ - clientId: "", - clientSecret: "", - scope: undefined, - }) - expect(fetchSpy).toHaveBeenCalledTimes(0) - expect(token).toBe(longLivedAccessToken) - }) - - test("throws if auth is not configured", async () => { - const fetchSpy = spyOnFetch({ - responseBody: accessToken, - }) - - const client = new DrupalClient(BASE_URL) - - await expect( - // @ts-ignore - client.getAccessToken({ clientId: clientIdSecret.clientId }) - ).rejects.toThrow( - "auth is not configured. See https://next-drupal.org/docs/client/auth" - ) - expect(fetchSpy).toHaveBeenCalledTimes(0) - }) - - test("throws if auth is not ClientIdSecret", async () => { - const fetchSpy = spyOnFetch({ - responseBody: accessToken, - }) - - const client = new DrupalClient(BASE_URL, { - auth: mocks.auth.basicAuth, - withAuth: true, - }) - - await expect( - // @ts-ignore - client.getAccessToken() - ).rejects.toThrow( - "'clientId' and 'clientSecret' required. See https://next-drupal.org/docs/client/auth" - ) - expect(fetchSpy).toHaveBeenCalledTimes(0) - }) - - test("fetches an access token", async () => { - spyOnFetch({ - responseBody: accessToken, - }) - - const logger = mockLogger() - const client = new DrupalClient(BASE_URL, { - auth: clientIdSecret, - debug: true, - logger, - }) - - const token = await client.getAccessToken() - expect(token).toEqual(accessToken) - expect(logger.debug).toHaveBeenCalledWith("Fetching new access token.") - }) - - test("re-uses access token", async () => { - spyOnFetchOnce({ - responseBody: accessToken, - }) - const fetchSpy = spyOnFetchOnce({ - responseBody: { - ...accessToken, - access_token: "differentAccessToken", - expires_in: 1800, - }, - }) - - const logger = mockLogger() - const client = new DrupalClient(BASE_URL, { - auth: clientIdSecret, - debug: true, - logger, - }) - - const token1 = await client.getAccessToken() - const token2 = await client.getAccessToken() - expect(token1).toEqual(accessToken) - expect(token1).toEqual(token2) - expect(logger.debug).toHaveBeenLastCalledWith( - "Using existing access token." - ) - expect(fetchSpy).toHaveBeenCalledTimes(1) - }) -}) - -describe("getAuthorizationHeader()", () => { - const accessToken = mocks.auth.accessToken - const basicAuth = mocks.auth.basicAuth - const basicAuthHeader = `Basic ${Buffer.from( - `${basicAuth.username}:${basicAuth.password}` - ).toString("base64")}` - const clientIdSecret = mocks.auth.clientIdSecret - const authCallback = mocks.auth.callback - const authHeader = mocks.auth.customAuthenticationHeader - - test("returns Basic Auth", async () => { - const auth: DrupalClientAuth = basicAuth - const logger = mockLogger() - const client = new DrupalClient(BASE_URL, { - auth: "is not used", - debug: true, - logger, - }) - - const header = await client.getAuthorizationHeader(auth) - - expect(header).toBe(basicAuthHeader) - expect(logger.debug).toHaveBeenLastCalledWith( - "Using basic authorization header." - ) - }) - - test("returns Client Id/Secret", async () => { - const auth: DrupalClientAuth = clientIdSecret - const logger = mockLogger() - const client = new DrupalClient(BASE_URL, { - auth: "is not used", - debug: true, - logger, - }) - jest - .spyOn(client, "getAccessToken") - .mockImplementation(async () => accessToken) - - const header = await client.getAuthorizationHeader(auth) - - expect(header).toBe(`Bearer ${accessToken.access_token}`) - expect(logger.debug).toHaveBeenLastCalledWith( - "Using access token authorization header retrieved from Client Id/Secret." - ) - }) - - test("returns Access Token", async () => { - const auth: DrupalClientAuth = accessToken - const logger = mockLogger() - const client = new DrupalClient(BASE_URL, { - auth: "is not used", - debug: true, - logger, - }) - - const header = await client.getAuthorizationHeader(auth) - - expect(header).toBe(`${auth.token_type} ${auth.access_token}`) - expect(logger.debug).toHaveBeenLastCalledWith( - "Using access token authorization header." - ) - }) - - test("returns auth header", async () => { - const logger = mockLogger() - const client = new DrupalClient(BASE_URL, { - auth: "is not used", - debug: true, - logger, - }) - - const header = await client.getAuthorizationHeader(authHeader) - - expect(header).toBe(authHeader) - expect(logger.debug).toHaveBeenLastCalledWith( - "Using custom authorization header." - ) - }) - - test("returns result of auth callback", async () => { - const auth: DrupalClientAuth = jest.fn(authCallback) - const logger = mockLogger() - const client = new DrupalClient(BASE_URL, { - auth: "is not used", - debug: true, - logger, - }) - - const header = await client.getAuthorizationHeader(auth) - - expect(header).toBe(authCallback()) - expect(auth).toBeCalledTimes(1) - expect(logger.debug).toHaveBeenLastCalledWith( - "Using custom authorization callback." - ) - }) - - test("throws an error if auth is undefined", async () => { - const auth = undefined - const client = new DrupalClient(BASE_URL, { - auth: "is not used", - }) - - await expect(client.getAuthorizationHeader(auth)).rejects.toThrow( - "auth is not configured. See https://next-drupal.org/docs/client/auth" - ) - }) - - test("throws an error if auth is unrecognized", async () => { - const auth = { - username: "admin", - token_type: "Bearer", - } - const client = new DrupalClient(BASE_URL, { - auth: "is not used", - }) - - // @ts-ignore - await expect(client.getAuthorizationHeader(auth)).rejects.toThrow( - "auth is not configured. See https://next-drupal.org/docs/client/auth" - ) - }) -}) diff --git a/packages/next-drupal/tests/DrupalClient/getters-setters.test.ts b/packages/next-drupal/tests/DrupalClient/getters-setters.test.ts deleted file mode 100644 index 351fb072..00000000 --- a/packages/next-drupal/tests/DrupalClient/getters-setters.test.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { afterEach, describe, expect, jest, test } from "@jest/globals" -import { - AccessToken, - DrupalClient, - DrupalClientAuthAccessToken, - DrupalClientAuthUsernamePassword, - DrupalClientOptions, -} from "../../src" -import { BASE_URL, mocks } from "../utils" - -afterEach(() => { - jest.restoreAllMocks() -}) - -describe("apiPrefix", () => { - test("get apiPrefix", () => { - const client = new DrupalClient(BASE_URL) - expect(client.apiPrefix).toBe("/jsonapi") - }) - test("set apiPrefix", () => { - const client = new DrupalClient(BASE_URL) - client.apiPrefix = "/api" - expect(client.apiPrefix).toBe("/api") - }) - test('set apiPrefix and prefixes with "/"', () => { - const client = new DrupalClient(BASE_URL) - client.apiPrefix = "api" - expect(client.apiPrefix).toBe("/api") - }) -}) - -describe("auth", () => { - describe("throws an error if invalid Basic Auth", () => { - test("missing username", () => { - expect(() => { - const client = new DrupalClient(BASE_URL) - // @ts-ignore - client.auth = { - password: "password", - } - }).toThrow( - "'username' and 'password' are required for auth. See https://next-drupal.org/docs/client/auth" - ) - }) - - test("missing password", () => { - expect(() => { - const client = new DrupalClient(BASE_URL) - // @ts-ignore - client.auth = { - username: "admin", - } - }).toThrow( - "'username' and 'password' are required for auth. See https://next-drupal.org/docs/client/auth" - ) - }) - }) - - describe("throws an error if invalid Access Token", () => { - test("missing access_token", () => { - expect(() => { - const client = new DrupalClient(BASE_URL) - // @ts-ignore - client.auth = { - token_type: mocks.auth.accessToken.token_type, - } - }).toThrow( - "'access_token' and 'token_type' are required for auth. See https://next-drupal.org/docs/client/auth" - ) - }) - - test("missing token_type", () => { - expect(() => { - const client = new DrupalClient(BASE_URL) - // @ts-ignore - client.auth = { - access_token: mocks.auth.accessToken.access_token, - } - }).toThrow( - "'access_token' and 'token_type' are required for auth. See https://next-drupal.org/docs/client/auth" - ) - }) - }) - - describe("throws an error if invalid Client ID/Secret", () => { - test("missing clientId", () => { - expect(() => { - const client = new DrupalClient(BASE_URL) - // @ts-ignore - client.auth = { - clientSecret: mocks.auth.clientIdSecret.clientSecret, - } - }).toThrow( - "'clientId' and 'clientSecret' are required for auth. See https://next-drupal.org/docs/client/auth" - ) - }) - - test("missing clientSecret", () => { - expect(() => { - const client = new DrupalClient(BASE_URL) - // @ts-ignore - client.auth = { - clientId: mocks.auth.clientIdSecret.clientId, - } - }).toThrow( - "'clientId' and 'clientSecret' are required for auth. See https://next-drupal.org/docs/client/auth" - ) - }) - }) - - test("sets Basic Auth", () => { - const basicAuth: DrupalClientAuthUsernamePassword = { - ...mocks.auth.basicAuth, - } - const client = new DrupalClient(BASE_URL) - client.auth = basicAuth - expect(client._auth).toMatchObject({ ...basicAuth }) - }) - - test("sets Access Token", () => { - const accessToken = { - ...mocks.auth.accessToken, - } - const client = new DrupalClient(BASE_URL) - client.auth = accessToken - expect(client._auth).toMatchObject({ ...accessToken }) - }) - - test("sets Client ID/Secret", () => { - const clientIdSecret = { - ...mocks.auth.clientIdSecret, - } - const client = new DrupalClient(BASE_URL) - client.auth = clientIdSecret - expect(client._auth).toMatchObject({ ...clientIdSecret }) - }) - - test("sets auth function", () => { - const authFunction = mocks.auth.function - const client = new DrupalClient(BASE_URL) - client.auth = authFunction - expect(client._auth).toBe(authFunction) - }) - - test("sets custom Authorization string", () => { - const authString = `${mocks.auth.customAuthenticationHeader}` - const client = new DrupalClient(BASE_URL) - client.auth = authString - expect(client._auth).toBe(authString) - }) - - test("sets a default access token url", () => { - const clientIdSecret = { - ...mocks.auth.clientIdSecret, - } - const client = new DrupalClient(BASE_URL) - client.auth = clientIdSecret - expect(client._auth.url).toBe("/oauth/token") - }) - - test("can override the default access token url", () => { - const clientIdSecret = { - ...mocks.auth.clientIdSecret, - url: "/custom/oauth/token", - } - const client = new DrupalClient(BASE_URL) - client.auth = clientIdSecret - expect(client._auth.url).toBe("/custom/oauth/token") - }) -}) - -describe("headers", () => { - describe("set headers", () => { - test("using key-value pairs", () => { - const headers = [ - ["Content-Type", "application/x-www-form-urlencoded"], - ["Accept", "application/json"], - ] as DrupalClientOptions["headers"] - const client = new DrupalClient(BASE_URL) - client.headers = headers - expect(client._headers).toBe(headers) - }) - - test("using object literal", () => { - const headers = { - "Content-Type": "application/x-www-form-urlencoded", - Accept: "application/json", - } as DrupalClientOptions["headers"] - const client = new DrupalClient(BASE_URL) - client.headers = headers - expect(client._headers).toBe(headers) - }) - - test("using Headers object", () => { - const headers = new Headers() - headers.append("Content-Type", "application/x-www-form-urlencoded") - headers.append("Accept", "application/json") - - const client = new DrupalClient(BASE_URL) - client.headers = headers - expect(client._headers).toBe(headers) - }) - }) -}) - -describe("token", () => { - test("set token", () => { - function getExpiresOn(token: AccessToken): number { - return Date.now() + token.expires_in * 1000 - } - - const accessToken = { - ...mocks.auth.accessToken, - } as DrupalClientAuthAccessToken - const before = getExpiresOn(accessToken) - - const client = new DrupalClient(BASE_URL) - client.token = accessToken - expect(client._token).toBe(accessToken) - expect(client.tokenExpiresOn).toBeGreaterThanOrEqual(before) - expect(client.tokenExpiresOn).toBeLessThanOrEqual(getExpiresOn(accessToken)) - }) -}) diff --git a/packages/next-drupal/tests/DrupalMenuTree/drupal-menu-tree.test.ts b/packages/next-drupal/tests/DrupalMenuTree/drupal-menu-tree.test.ts new file mode 100644 index 00000000..aa8c3f3f --- /dev/null +++ b/packages/next-drupal/tests/DrupalMenuTree/drupal-menu-tree.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, test } from "@jest/globals" +import { DrupalMenuTree } from "../../src/menu-tree" +import type { DrupalMenuItem } from "../../src" + +const menuItems = [ + { id: "1", parent: "" }, + { id: "2", parent: "" }, + { id: "3", parent: "" }, + { id: "4", parent: "1" }, + { id: "5", parent: "1" }, + { id: "6", parent: "1" }, + { id: "7", parent: "4" }, +] + +test("extends Array", () => { + const tree = new DrupalMenuTree([]) + + expect(Array.isArray(tree)).toBe(true) +}) + +describe("parentId parameter", () => { + test("has no parent ID by default", () => { + const tree = new DrupalMenuTree([]) + + expect(tree.parentId).toBe("") + }) + + test("has the given parent ID", () => { + const tree = new DrupalMenuTree([], "42") + + expect(tree.parentId).toBe("42") + }) + + test("child trees have the correct parentId", () => { + const tree = new DrupalMenuTree(menuItems) + + expect(tree[0]?.items?.parentId).toBe("1") + expect(tree[0]?.items?.[0]?.items?.parentId).toBe("4") + }) +}) + +describe("depth parameter", () => { + test("has a depth of 1 by default", () => { + const tree = new DrupalMenuTree([]) + + expect(tree.depth).toBe(1) + }) + + test("child trees have the correct depth", () => { + const tree = new DrupalMenuTree(menuItems) + + expect(tree[0]?.items?.depth).toBe(2) + expect(tree[0]?.items?.[0]?.items?.depth).toBe(3) + }) +}) + +test("assembles a tree", async () => { + const tree = new DrupalMenuTree(menuItems) + + expect(tree).toMatchInlineSnapshot(` +DrupalMenuTree [ + { + "id": "1", + "items": DrupalMenuTree [ + { + "id": "4", + "items": DrupalMenuTree [ + { + "id": "7", + "items": undefined, + "parent": "4", + }, + ], + "parent": "1", + }, + { + "id": "5", + "items": undefined, + "parent": "1", + }, + { + "id": "6", + "items": undefined, + "parent": "1", + }, + ], + "parent": "", + }, + { + "id": "2", + "items": undefined, + "parent": "", + }, + { + "id": "3", + "items": undefined, + "parent": "", + }, +] +`) +}) diff --git a/packages/next-drupal/tests/DrupalClient/__snapshots__/basic-methods.test.ts.snap b/packages/next-drupal/tests/NextDrupal/__snapshots__/basic-methods.test.ts.snap similarity index 100% rename from packages/next-drupal/tests/DrupalClient/__snapshots__/basic-methods.test.ts.snap rename to packages/next-drupal/tests/NextDrupal/__snapshots__/basic-methods.test.ts.snap diff --git a/packages/next-drupal/tests/DrupalClient/__snapshots__/resource-methods.test.ts.snap b/packages/next-drupal/tests/NextDrupal/__snapshots__/resource-methods.test.ts.snap similarity index 99% rename from packages/next-drupal/tests/DrupalClient/__snapshots__/resource-methods.test.ts.snap rename to packages/next-drupal/tests/NextDrupal/__snapshots__/resource-methods.test.ts.snap index f75713ac..dc206f4c 100644 --- a/packages/next-drupal/tests/DrupalClient/__snapshots__/resource-methods.test.ts.snap +++ b/packages/next-drupal/tests/NextDrupal/__snapshots__/resource-methods.test.ts.snap @@ -487,12 +487,13 @@ exports[`getMenu() fetches menu items for a menu 1`] = ` "weight": "30", }, ], - "tree": [ + "tree": DrupalMenuTree [ { "description": "", "enabled": true, "expanded": false, "id": "standard.front_page", + "items": undefined, "menu_name": "main", "meta": [], "options": [], @@ -512,6 +513,7 @@ exports[`getMenu() fetches menu items for a menu 1`] = ` "enabled": true, "expanded": false, "id": "views_view:views.featured_articles.page_1", + "items": undefined, "menu_name": "main", "meta": { "display_id": "page_1", @@ -534,6 +536,7 @@ exports[`getMenu() fetches menu items for a menu 1`] = ` "enabled": true, "expanded": false, "id": "views_view:views.recipes.page_1", + "items": undefined, "menu_name": "main", "meta": { "display_id": "page_1", @@ -622,12 +625,13 @@ exports[`getMenu() fetches menu items for a menu with locale 1`] = ` "weight": "30", }, ], - "tree": [ + "tree": DrupalMenuTree [ { "description": "", "enabled": true, "expanded": false, "id": "standard.front_page", + "items": undefined, "menu_name": "main", "meta": [], "options": [], @@ -647,6 +651,7 @@ exports[`getMenu() fetches menu items for a menu with locale 1`] = ` "enabled": true, "expanded": false, "id": "views_view:views.featured_articles.page_1", + "items": undefined, "menu_name": "main", "meta": { "display_id": "page_1", @@ -669,6 +674,7 @@ exports[`getMenu() fetches menu items for a menu with locale 1`] = ` "enabled": true, "expanded": false, "id": "views_view:views.recipes.page_1", + "items": undefined, "menu_name": "main", "meta": { "display_id": "page_1", @@ -1082,7 +1088,7 @@ exports[`getResource() fetches raw data 1`] = ` }, "links": { "self": { - "href": "https://tests.next-drupal.org/en/jsonapi/node/recipe/71e04ead-4cc7-416c-b9ca-60b635fdc50f", + "href": "https://tests.next-drupal.org/jsonapi/node/recipe/71e04ead-4cc7-416c-b9ca-60b635fdc50f", }, }, } @@ -1457,7 +1463,7 @@ exports[`getResourceByPath() fetches raw data 1`] = ` }, "links": { "self": { - "href": "https://tests.next-drupal.org/en/jsonapi/node/recipe/71e04ead-4cc7-416c-b9ca-60b635fdc50f", + "href": "https://tests.next-drupal.org/jsonapi/node/recipe/71e04ead-4cc7-416c-b9ca-60b635fdc50f", }, }, } diff --git a/packages/next-drupal/tests/NextDrupal/basic-methods.test.ts b/packages/next-drupal/tests/NextDrupal/basic-methods.test.ts new file mode 100644 index 00000000..9d288c25 --- /dev/null +++ b/packages/next-drupal/tests/NextDrupal/basic-methods.test.ts @@ -0,0 +1,107 @@ +import { afterEach, describe, expect, jest, test } from "@jest/globals" +import { NextDrupal } from "../../src" +import { BASE_URL, mockLogger } from "../utils" +import type { DrupalNode, JsonDeserializer } from "../../src" + +jest.setTimeout(10000) + +afterEach(() => { + jest.restoreAllMocks() +}) + +describe("deserialize()", () => { + test("deserializes JSON:API resource", async () => { + const drupal = new NextDrupal(BASE_URL) + const url = await drupal.buildEndpoint({ + resourceType: "node--article", + path: "/52837ad0-f218-46bd-a106-5710336b7053", + searchParams: { + include: "field_tags", + }, + }) + + const response = await drupal.fetch(url) + expect(response.status).toBe(200) + const json = await response.json() + const article = drupal.deserialize(json) as DrupalNode + + expect(article).toMatchSnapshot() + expect(article.id).toEqual("52837ad0-f218-46bd-a106-5710336b7053") + expect(article.field_tags).toHaveLength(3) + }) + + test("deserializes JSON:API collection", async () => { + const drupal = new NextDrupal(BASE_URL) + const url = await drupal.buildEndpoint({ + resourceType: "node--article", + searchParams: { + getQueryObject: () => ({ + "fields[node--article]": "title", + }), + }, + }) + + const response = await drupal.fetch(url) + expect(response.status).toBe(200) + const json = await response.json() + const articles = drupal.deserialize(json) as DrupalNode[] + + expect(articles).toMatchSnapshot() + }) + + test("allows for custom data serializer", async () => { + const deserializer: JsonDeserializer = ( + body: { data: { id: string; attributes: { title: string } } }, + options: { pathPrefix: string } + ) => { + return { + id: body.data.id, + title: `${options.pathPrefix}: ${body.data.attributes.title}`, + } + } + const drupal = new NextDrupal(BASE_URL, { + deserializer, + }) + const url = await drupal.buildEndpoint({ + resourceType: "node--article", + path: "/52837ad0-f218-46bd-a106-5710336b7053", + }) + + const response = await drupal.fetch(url) + expect(response.status).toBe(200) + const json = await response.json() + const article = drupal.deserialize(json, { + pathPrefix: "TITLE", + }) as DrupalNode + + expect(article).toMatchSnapshot() + expect(article.id).toEqual("52837ad0-f218-46bd-a106-5710336b7053") + expect(article.title).toEqual(`TITLE: ${json.data.attributes.title}`) + }) + + test("returns null if no body", () => { + const drupal = new NextDrupal(BASE_URL) + expect(drupal.deserialize("")).toBe(null) + }) +}) + +describe("logOrThrowError()", () => { + test("throws the error", () => { + const drupal = new NextDrupal(BASE_URL) + expect(() => { + drupal.logOrThrowError(new Error("Example error")) + }).toThrow("Example error") + }) + + test("logs the error when throwJsonApiErrors is false", () => { + const logger = mockLogger() + const drupal = new NextDrupal(BASE_URL, { + throwJsonApiErrors: false, + logger, + }) + expect(() => { + drupal.logOrThrowError(new Error("Example error")) + }).not.toThrow() + expect(logger.error).toHaveBeenCalledWith(new Error("Example error")) + }) +}) diff --git a/packages/next-drupal/tests/NextDrupal/constructor.test.ts b/packages/next-drupal/tests/NextDrupal/constructor.test.ts new file mode 100644 index 00000000..4a06ff3a --- /dev/null +++ b/packages/next-drupal/tests/NextDrupal/constructor.test.ts @@ -0,0 +1,208 @@ +import { Jsona } from "jsona" +import { + afterEach, + beforeEach, + describe, + expect, + jest, + test, +} from "@jest/globals" +import { NextDrupal, NextDrupalBase } from "../../src" +import { BASE_URL } from "../utils" +import type { JsonDeserializer, NextDrupalOptions } from "../../src" + +jest.mock("jsona", () => { + // Re-use the same method mock for each Jsona mock object. + const deserialize = jest.fn() + function JsonaMock() { + return { + deserialize, + } + } + + return { + __esModule: true, + Jsona: jest.fn(JsonaMock), + } +}) + +beforeEach(() => { + Jsona.mockClear() +}) + +afterEach(() => { + jest.restoreAllMocks() +}) + +describe("baseUrl parameter", () => { + const env = process.env + + beforeEach(() => { + jest.resetModules() + process.env = { ...env } + }) + + afterEach(() => { + process.env = env + }) + + test("turns throwJsonApiErrors off in production", () => { + process.env = { + ...process.env, + NODE_ENV: "production", + } + + const drupal = new NextDrupal(BASE_URL) + expect(drupal.throwJsonApiErrors).toBe(false) + }) + + test("returns a NextDrupal", () => { + expect(new NextDrupal(BASE_URL)).toBeInstanceOf(NextDrupal) + expect(new NextDrupal(BASE_URL)).toBeInstanceOf(NextDrupalBase) + }) +}) + +describe("options parameter", () => { + describe("apiPrefix", () => { + test('defaults to "/jsonapi"', () => { + const drupal = new NextDrupal(BASE_URL) + expect(drupal.apiPrefix).toBe("/jsonapi") + }) + + test("sets the apiPrefix", () => { + const customEndPoint = "/customapi" + const drupal = new NextDrupal(BASE_URL, { + apiPrefix: customEndPoint, + }) + expect(drupal.apiPrefix).toBe(customEndPoint) + }) + }) + + describe("cache", () => { + test("defaults to `null`", () => { + const drupal = new NextDrupal(BASE_URL) + expect(drupal.cache).toBe(null) + }) + + test("sets the cache storage", () => { + const customCache: NextDrupal["cache"] = { + async get(key) { + // + }, + async set(key, value, ttl?: number) { + // + }, + } + const drupal = new NextDrupal(BASE_URL, { + cache: customCache, + }) + expect(drupal.cache).toBe(customCache) + }) + }) + + describe("deserializer", () => { + test("defaults to `Jsona.deserialize`", () => { + const drupal = new NextDrupal(BASE_URL) + expect(drupal.deserializer.name).toBe("jsonaDeserialize") + expect(drupal.deserializer.length).toBe(2) + expect(Jsona).toBeCalledTimes(1) + + const deserializeMock = new Jsona().deserialize + deserializeMock.mockClear() + const args: Parameters = [{}, { options: true }] + drupal.deserialize(...args) + expect(deserializeMock).toBeCalledTimes(1) + expect(deserializeMock).toHaveBeenLastCalledWith(...args) + }) + + test("sets up a custom deserializer", () => { + const customDeserializer: NextDrupalOptions["deserializer"] = + function deserialize( + body: Record, + options?: Record + ): unknown { + return { + deserialized: true, + } + } + + const drupal = new NextDrupal(BASE_URL, { + deserializer: customDeserializer, + }) + expect(drupal.deserializer).toBe(customDeserializer) + }) + }) + + describe("frontPage", () => { + test('defaults to "/home"', () => { + const drupal = new NextDrupal(BASE_URL) + expect(drupal.frontPage).toBe("/home") + }) + + test("sets up a custom frontPage", () => { + const customFrontPage = "/front" + + const drupal = new NextDrupal(BASE_URL, { + frontPage: customFrontPage, + }) + expect(drupal.frontPage).toBe(customFrontPage) + }) + }) + + describe("headers", () => { + test("defaults to `Content-Type`/`Accept`", () => { + const drupal = new NextDrupal(BASE_URL) + expect(Object.fromEntries(drupal.headers.entries())).toMatchObject({ + "content-type": "application/vnd.api+json", + accept: "application/vnd.api+json", + }) + }) + + test("sets custom headers", () => { + const customHeaders = { + CustomContentType: "application/json", + CustomAccept: "application/json", + } + const expectedHeaders = {} + Object.keys(customHeaders).forEach((header) => { + expectedHeaders[header.toLowerCase()] = customHeaders[header] + }) + + const drupal = new NextDrupal(BASE_URL, { + headers: customHeaders, + }) + + expect(Object.fromEntries(drupal.headers.entries())).toMatchObject( + expectedHeaders + ) + }) + }) + + describe("throwJsonApiErrors", () => { + test("defaults to `true`", () => { + const drupal = new NextDrupal(BASE_URL) + expect(drupal.throwJsonApiErrors).toBe(true) + }) + + test("can be set to `false`", () => { + const drupal = new NextDrupal(BASE_URL, { + throwJsonApiErrors: false, + }) + expect(drupal.throwJsonApiErrors).toBe(false) + }) + }) + + describe("useDefaultEndpoints", () => { + test("defaults to `true`", () => { + const drupal = new NextDrupal(BASE_URL) + expect(drupal.useDefaultEndpoints).toBe(true) + }) + + test("can be set to `false`", () => { + const drupal = new NextDrupal(BASE_URL, { + useDefaultEndpoints: false, + }) + expect(drupal.useDefaultEndpoints).toBe(false) + }) + }) +}) diff --git a/packages/next-drupal/tests/DrupalClient/crud-methods.test.ts b/packages/next-drupal/tests/NextDrupal/crud-methods.test.ts similarity index 80% rename from packages/next-drupal/tests/DrupalClient/crud-methods.test.ts rename to packages/next-drupal/tests/NextDrupal/crud-methods.test.ts index cb8e4015..a53fb38f 100644 --- a/packages/next-drupal/tests/DrupalClient/crud-methods.test.ts +++ b/packages/next-drupal/tests/NextDrupal/crud-methods.test.ts @@ -7,7 +7,7 @@ import { jest, test, } from "@jest/globals" -import { DrupalClient } from "../../src" +import { NextDrupal } from "../../src" import { BASE_URL, deleteTestNodes, @@ -38,9 +38,9 @@ afterAll(async () => { describe("createResource()", () => { test("creates a resource", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupal(BASE_URL) - const article = await client.createResource( + const article = await drupal.createResource( "node--article", { data: { @@ -62,7 +62,7 @@ describe("createResource()", () => { }) test("creates a resource with a relationship", async () => { - const client = new DrupalClient(BASE_URL, { + const drupal = new NextDrupal(BASE_URL, { auth: { username: process.env.DRUPAL_USERNAME, password: process.env.DRUPAL_PASSWORD, @@ -70,7 +70,7 @@ describe("createResource()", () => { }) // Find an image media. - const [mediaImage] = await client.getResourceCollection("media--image", { + const [mediaImage] = await drupal.getResourceCollection("media--image", { params: { "page[limit]": 1, "filter[status]": 1, @@ -78,7 +78,7 @@ describe("createResource()", () => { }, }) - const article = await client.createResource( + const article = await drupal.createResource( "node--article", { data: { @@ -108,14 +108,14 @@ describe("createResource()", () => { }) test("creates a localized resource", async () => { - const client = new DrupalClient(BASE_URL, { + const drupal = new NextDrupal(BASE_URL, { auth: { username: process.env.DRUPAL_USERNAME, password: process.env.DRUPAL_PASSWORD, }, }) - const article = await client.createResource("node--article", { + const article = await drupal.createResource("node--article", { data: { attributes: { title: "TEST Article in spanish", @@ -128,7 +128,7 @@ describe("createResource()", () => { }) test("throws an error for missing required attributes", async () => { - const client = new DrupalClient(BASE_URL, { + const drupal = new NextDrupal(BASE_URL, { auth: { username: process.env.DRUPAL_USERNAME, password: process.env.DRUPAL_PASSWORD, @@ -136,7 +136,7 @@ describe("createResource()", () => { }) await expect( - client.createResource("node--article", { + drupal.createResource("node--article", { data: { attributes: {}, }, @@ -147,7 +147,7 @@ describe("createResource()", () => { }) test("throws an error for invalid attributes", async () => { - const client = new DrupalClient(BASE_URL, { + const drupal = new NextDrupal(BASE_URL, { auth: { username: process.env.DRUPAL_USERNAME, password: process.env.DRUPAL_PASSWORD, @@ -155,7 +155,7 @@ describe("createResource()", () => { }) await expect( - client.createResource("node--article", { + drupal.createResource("node--article", { data: { attributes: { title: "TEST: Article", @@ -170,7 +170,7 @@ describe("createResource()", () => { ) await expect( - client.createResource("node--article", { + drupal.createResource("node--article", { data: { attributes: { title: "TEST: Article", @@ -202,15 +202,14 @@ describe("createFileResource()", () => { test("constructs the API path from body and options", async () => { const logger = mockLogger() - const client = new DrupalClient("https://example.com", { - useDefaultResourceTypeEntry: true, + const drupal = new NextDrupal("https://example.com", { debug: true, logger, }) const type = "type--from-first-argument" const fetchSpy = spyOnFetch({ responseBody: mockResponseData }) - await client.createFileResource(type, mockBody, { + await drupal.createFileResource(type, mockBody, { withAuth: false, params: { include: "extra_field" }, }) @@ -224,13 +223,11 @@ describe("createFileResource()", () => { }) test("constructs the API path using non-default locale", async () => { - const client = new DrupalClient("https://example.com", { - useDefaultResourceTypeEntry: true, - }) + const drupal = new NextDrupal("https://example.com") const type = "type--from-first-argument" const fetchSpy = spyOnFetch({ responseBody: mockResponseData }) - await client.createFileResource(type, mockBody, { + await drupal.createFileResource(type, mockBody, { withAuth: false, params: { include: "extra_field" }, locale: "es", @@ -243,12 +240,10 @@ describe("createFileResource()", () => { }) test("returns the deserialized data", async () => { - const client = new DrupalClient(BASE_URL, { - useDefaultResourceTypeEntry: true, - }) + const drupal = new NextDrupal(BASE_URL) spyOnFetch({ responseBody: mockResponseData }) - const result = await client.createFileResource("ignored", mockBody, { + const result = await drupal.createFileResource("ignored", mockBody, { withAuth: false, }) @@ -257,12 +252,10 @@ describe("createFileResource()", () => { }) test("optionally returns the raw data", async () => { - const client = new DrupalClient(BASE_URL, { - useDefaultResourceTypeEntry: true, - }) + const drupal = new NextDrupal(BASE_URL) spyOnFetch({ responseBody: mockResponseData }) - const result = await client.createFileResource("ignored", mockBody, { + const result = await drupal.createFileResource("ignored", mockBody, { withAuth: false, deserialize: false, }) @@ -274,9 +267,7 @@ describe("createFileResource()", () => { }) test("throws error if response is not ok", async () => { - const client = new DrupalClient(BASE_URL, { - useDefaultResourceTypeEntry: true, - }) + const drupal = new NextDrupal(BASE_URL) const message = "mock error" spyOnFetch({ responseBody: { message }, @@ -287,7 +278,7 @@ describe("createFileResource()", () => { }) await expect( - client.createFileResource("ignored", mockBody, { + drupal.createFileResource("ignored", mockBody, { withAuth: false, }) ).rejects.toThrow(message) @@ -296,13 +287,13 @@ describe("createFileResource()", () => { describe("updateResource()", () => { test("updates a resource", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupal(BASE_URL) const basic = Buffer.from( `${process.env.DRUPAL_USERNAME}:${process.env.DRUPAL_PASSWORD}` ).toString("base64") - const article = await client.createResource( + const article = await drupal.createResource( "node--article", { data: { @@ -316,7 +307,7 @@ describe("updateResource()", () => { } ) - const updatedArticle = await client.updateResource( + const updatedArticle = await drupal.updateResource( "node--article", article.id, { @@ -340,12 +331,12 @@ describe("updateResource()", () => { `${process.env.DRUPAL_USERNAME}:${process.env.DRUPAL_PASSWORD}` ).toString("base64") - const client = new DrupalClient(BASE_URL, { + const drupal = new NextDrupal(BASE_URL, { auth: `Basic ${basic}`, }) // Create an article. - const article = await client.createResource("node--article", { + const article = await drupal.createResource("node--article", { data: { attributes: { title: "TEST New article", @@ -354,7 +345,7 @@ describe("updateResource()", () => { }) // Find an image media. - const [mediaImage] = await client.getResourceCollection("media--image", { + const [mediaImage] = await drupal.getResourceCollection("media--image", { params: { "page[limit]": 1, "filter[status]": 1, @@ -363,7 +354,7 @@ describe("updateResource()", () => { }) // Attach the media image to the article. - const updatedArticle = await client.updateResource( + const updatedArticle = await drupal.updateResource( "node--article", article.id, { @@ -396,14 +387,14 @@ describe("updateResource()", () => { }) test("throws an error for missing required attributes", async () => { - const client = new DrupalClient(BASE_URL, { + const drupal = new NextDrupal(BASE_URL, { auth: { username: process.env.DRUPAL_USERNAME, password: process.env.DRUPAL_PASSWORD, }, }) - const article = await client.createResource("node--article", { + const article = await drupal.createResource("node--article", { data: { attributes: { title: "TEST New article", @@ -412,7 +403,7 @@ describe("updateResource()", () => { }) await expect( - client.updateResource("node--article", article.id, { + drupal.updateResource("node--article", article.id, { data: { attributes: { title: null, @@ -425,14 +416,14 @@ describe("updateResource()", () => { }) test("throws an error for invalid attributes", async () => { - const client = new DrupalClient(BASE_URL, { + const drupal = new NextDrupal(BASE_URL, { auth: { username: process.env.DRUPAL_USERNAME, password: process.env.DRUPAL_PASSWORD, }, }) - const article = await client.createResource("node--article", { + const article = await drupal.createResource("node--article", { data: { attributes: { title: "TEST New article", @@ -441,7 +432,7 @@ describe("updateResource()", () => { }) await expect( - client.updateResource("node--article", article.id, { + drupal.updateResource("node--article", article.id, { data: { attributes: { body: { @@ -455,7 +446,7 @@ describe("updateResource()", () => { ) await expect( - client.updateResource("node--article", article.id, { + drupal.updateResource("node--article", article.id, { data: { attributes: { body: { @@ -473,14 +464,14 @@ describe("updateResource()", () => { describe("deleteResource()", () => { test("deletes a resource", async () => { - const client = new DrupalClient(BASE_URL, { + const drupal = new NextDrupal(BASE_URL, { auth: { username: process.env.DRUPAL_USERNAME, password: process.env.DRUPAL_PASSWORD, }, }) - const article = await client.createResource("node--article", { + const article = await drupal.createResource("node--article", { data: { attributes: { title: "TEST New article", @@ -488,19 +479,19 @@ describe("deleteResource()", () => { }, }) - const deleted = await client.deleteResource("node--article", article.id) + const deleted = await drupal.deleteResource("node--article", article.id) expect(deleted).toBe(true) await expect( - client.getResource("node--article", article.id) + drupal.getResource("node--article", article.id) ).rejects.toThrow( '404 Not Found\nThe "entity" parameter was not converted for the path "/jsonapi/node/article/{entity}" (route name: "jsonapi.node--article.individual")' ) }) test("throws an error for invalid resource", async () => { - const client = new DrupalClient(BASE_URL, { + const drupal = new NextDrupal(BASE_URL, { auth: { username: process.env.DRUPAL_USERNAME, password: process.env.DRUPAL_PASSWORD, @@ -508,7 +499,7 @@ describe("deleteResource()", () => { }) await expect( - client.deleteResource("node--article", "invalid-id") + drupal.deleteResource("node--article", "invalid-id") ).rejects.toThrow( '404 Not Found\nThe "entity" parameter was not converted for the path "/jsonapi/node/article/{entity}" (route name: "jsonapi.node--article.individual.delete")' ) diff --git a/packages/next-drupal/tests/DrupalClient/resource-methods.test.ts b/packages/next-drupal/tests/NextDrupal/resource-methods.test.ts similarity index 55% rename from packages/next-drupal/tests/DrupalClient/resource-methods.test.ts rename to packages/next-drupal/tests/NextDrupal/resource-methods.test.ts index 51fc43b1..3448bf85 100644 --- a/packages/next-drupal/tests/DrupalClient/resource-methods.test.ts +++ b/packages/next-drupal/tests/NextDrupal/resource-methods.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, jest, test } from "@jest/globals" -import { DrupalClient } from "../../src" -import { BASE_URL, mocks, spyOnFetch } from "../utils" +import { NextDrupal } from "../../src" +import { BASE_URL, mockLogger, mocks, spyOnFetch } from "../utils" import type { DrupalNode, DrupalSearchApiJsonApiResponse } from "../../src" jest.setTimeout(10000) @@ -9,63 +9,146 @@ afterEach(() => { jest.restoreAllMocks() }) -describe("buildMenuTree()", () => { - test.todo("add tests") -}) +describe("buildEndpoint()", () => { + const drupal = new NextDrupal(BASE_URL) -describe("getEntryForResourceType()", () => { - test("returns the JSON:API entry for a resource type", async () => { - const client = new DrupalClient(BASE_URL) - const getIndexSpy = jest.spyOn(client, "getIndex") + test("returns a URL string", async () => { + const endpoint = await drupal.buildEndpoint() - const recipeEntry = await client.getEntryForResourceType("node--recipe") - expect(recipeEntry).toMatch(`${BASE_URL}/en/jsonapi/node/recipe`) - expect(getIndexSpy).toHaveBeenCalledTimes(1) + expect(typeof endpoint).toBe("string") + }) - const articleEntry = await client.getEntryForResourceType("node--article") - expect(articleEntry).toMatch(`${BASE_URL}/en/jsonapi/node/article`) - expect(getIndexSpy).toHaveBeenCalledTimes(2) + test("adds the apiPrefix", async () => { + const endpoint = await drupal.buildEndpoint() + + expect(endpoint).toBe(`${BASE_URL}/jsonapi`) }) - test("assembles JSON:API entry without fetching index", async () => { - const client = new DrupalClient(BASE_URL, { - useDefaultResourceTypeEntry: true, + test("adds a locale prefix", async () => { + const endpoint = await drupal.buildEndpoint({ + locale: "es", + }) + + expect(endpoint).toBe(`${BASE_URL}/es/jsonapi`) + }) + + test("adds the default resource url", async () => { + const drupal = new NextDrupal(BASE_URL) + const getIndexSpy = jest.spyOn(drupal, "getIndex") + + let endpoint = await drupal.buildEndpoint({ + resourceType: "node--article", }) - const getIndexSpy = jest.spyOn(client, "getIndex") + expect(endpoint).toMatch(`${BASE_URL}/jsonapi/node/article`) + + endpoint = await drupal.buildEndpoint({ + resourceType: "menu_items", + }) + expect(endpoint).toMatch(`${BASE_URL}/jsonapi/menu_items`) - const recipeEntry = await client.getEntryForResourceType("node--article") - expect(recipeEntry).toMatch(`${BASE_URL}/jsonapi/node/article`) expect(getIndexSpy).toHaveBeenCalledTimes(0) }) + test("fetches the resource url and does not add apiPrefix or locale", async () => { + const drupal = new NextDrupal(BASE_URL, { useDefaultEndpoints: false }) + const expected = `${BASE_URL}/locale/someapi/node/article` + const getIndexSpy = jest + .spyOn(drupal, "fetchResourceEndpoint") + .mockImplementation(async () => new URL(expected)) + + const endpoint = await drupal.buildEndpoint({ + locale: "es", + resourceType: "node--article", + }) + expect(endpoint).not.toContain("/jsonapi") + expect(endpoint).not.toContain("/es") + expect(endpoint).toMatch(expected) + expect(getIndexSpy).toHaveBeenCalledTimes(1) + }) + + test("adds the path", async () => { + const endpoint = await drupal.buildEndpoint({ path: "/some-path" }) + + expect(endpoint).toBe(`${BASE_URL}/jsonapi/some-path`) + }) + + test('ensures the path starts with "/"', async () => { + const endpoint = await drupal.buildEndpoint({ + path: "some-path", + }) + + expect(endpoint).toBe(`${BASE_URL}/jsonapi/some-path`) + }) + + test("adds the search params", async () => { + const endpoint = await drupal.buildEndpoint({ + path: "/zoo", + searchParams: { animal: "unicorn" }, + }) + + expect(endpoint).toBe(`${BASE_URL}/jsonapi/zoo?animal=unicorn`) + }) + + test("adds locale then apiPrefix then resource then path", async () => { + const endpoint = await drupal.buildEndpoint({ + locale: "en", + resourceType: "city--sandiego", + path: "/zoo", + searchParams: { animal: "unicorn" }, + }) + + expect(endpoint).toBe( + `${BASE_URL}/en/jsonapi/city/sandiego/zoo?animal=unicorn` + ) + }) +}) + +describe("fetchResourceEndpoint()", () => { + test("returns the JSON:API entry for a resource type", async () => { + const drupal = new NextDrupal(BASE_URL, { + useDefaultEndpoints: false, + }) + const getIndexSpy = jest.spyOn(drupal, "getIndex") + + const recipeEntry = await drupal.fetchResourceEndpoint("node--recipe") + expect(recipeEntry.toString()).toMatch(`${BASE_URL}/en/jsonapi/node/recipe`) + expect(getIndexSpy).toHaveBeenCalledTimes(1) + + const articleEntry = await drupal.fetchResourceEndpoint("node--article") + expect(articleEntry.toString()).toMatch( + `${BASE_URL}/en/jsonapi/node/article` + ) + expect(getIndexSpy).toHaveBeenCalledTimes(2) + }) + test("throws an error if resource type does not exist", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupal(BASE_URL) await expect( - client.getEntryForResourceType("RESOURCE-DOES-NOT-EXIST") + drupal.fetchResourceEndpoint("RESOURCE-DOES-NOT-EXIST") ).rejects.toThrow("Resource of type 'RESOURCE-DOES-NOT-EXIST' not found.") }) }) describe("getIndex()", () => { test("fetches the JSON:API index", async () => { - const client = new DrupalClient(BASE_URL) - const index = await client.getIndex() + const drupal = new NextDrupal(BASE_URL) + const index = await drupal.getIndex() expect(index).toMatchSnapshot() }) test("fetches the JSON:API index with locale", async () => { - const client = new DrupalClient(BASE_URL) - const index = await client.getIndex("es") + const drupal = new NextDrupal(BASE_URL) + const index = await drupal.getIndex("es") expect(index).toMatchSnapshot() }) test("throws error for invalid base url", async () => { - const client = new DrupalClient("https://example.com") + const drupal = new NextDrupal("https://example.com") - await expect(client.getIndex()).rejects.toThrow( + await expect(drupal.getIndex()).rejects.toThrow( "Failed to fetch JSON:API index at https://example.com/jsonapi" ) }) @@ -73,17 +156,17 @@ describe("getIndex()", () => { describe("getMenu()", () => { test("fetches menu items for a menu", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupal(BASE_URL) - const menu = await client.getMenu("main") + const menu = await drupal.getMenu("main") expect(menu).toMatchSnapshot() }) test("fetches menu items for a menu with locale", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupal(BASE_URL) - const menu = await client.getMenu("main", { + const menu = await drupal.getMenu("main", { locale: "es", defaultLocale: "en", }) @@ -92,9 +175,9 @@ describe("getMenu()", () => { }) test("fetches menu items for a menu with params", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupal(BASE_URL) - const menu = await client.getMenu("main", { + const menu = await drupal.getMenu("main", { params: { "fields[menu_link_content--menu_link_content]": "title", }, @@ -104,48 +187,42 @@ describe("getMenu()", () => { }) test("throws an error for invalid menu name", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupal(BASE_URL) - await expect(client.getMenu("INVALID")).rejects.toThrow( + await expect(drupal.getMenu("INVALID")).rejects.toThrow( '404 Not Found\nThe "menu" parameter was not converted for the path "/jsonapi/menu_items/{menu}" (route name: "jsonapi_menu_items.menu")' ) }) test("makes un-authenticated requests by default", async () => { - const client = new DrupalClient(BASE_URL) - const fetchSpy = jest.spyOn(client, "fetch") + const drupal = new NextDrupal(BASE_URL) + const drupalFetchSpy = jest.spyOn(drupal, "fetch") + + await drupal.getMenu("main") - await client.getMenu("main") - expect(fetchSpy).toHaveBeenCalledWith(expect.anything(), { + expect(drupalFetchSpy).toHaveBeenCalledWith(expect.anything(), { withAuth: false, }) }) test("makes authenticated requests with withAuth option", async () => { - const client = new DrupalClient(BASE_URL, { - useDefaultResourceTypeEntry: true, + const drupal = new NextDrupal(BASE_URL, { auth: `Bearer sample-token`, }) const fetchSpy = spyOnFetch() - jest.spyOn(client, "getAccessToken") - await client.getMenu("main", { withAuth: true }) + await drupal.getMenu("main", { withAuth: true }) - expect(fetchSpy).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: "Bearer sample-token", - }), - }) - ) + expect( + (fetchSpy.mock.lastCall[1].headers as Headers).get("Authorization") + ).toBe("Bearer sample-token") }) }) describe("getResource()", () => { test("fetches a resource by uuid", async () => { - const client = new DrupalClient(BASE_URL) - const recipe = await client.getResource( + const drupal = new NextDrupal(BASE_URL) + const recipe = await drupal.getResource( "node--recipe", "71e04ead-4cc7-416c-b9ca-60b635fdc50f" ) @@ -154,8 +231,8 @@ describe("getResource()", () => { }) test("fetches a resource by uuid with params", async () => { - const client = new DrupalClient(BASE_URL) - const recipe = await client.getResource( + const drupal = new NextDrupal(BASE_URL) + const recipe = await drupal.getResource( "node--recipe", "71e04ead-4cc7-416c-b9ca-60b635fdc50f", { @@ -169,8 +246,8 @@ describe("getResource()", () => { }) test("fetches a resource using locale", async () => { - const client = new DrupalClient(BASE_URL) - const recipe = await client.getResource( + const drupal = new NextDrupal(BASE_URL) + const recipe = await drupal.getResource( "node--recipe", "71e04ead-4cc7-416c-b9ca-60b635fdc50f", { @@ -186,10 +263,10 @@ describe("getResource()", () => { }) test("fetches raw data", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupal(BASE_URL, { useDefaultEndpoints: false }) await expect( - client.getResource( + drupal.getResource( "node--recipe", "71e04ead-4cc7-416c-b9ca-60b635fdc50f", { @@ -200,8 +277,8 @@ describe("getResource()", () => { }) test("fetches a resource by revision", async () => { - const client = new DrupalClient(BASE_URL) - const recipe = await client.getResource( + const drupal = new NextDrupal(BASE_URL) + const recipe = await drupal.getResource( "node--recipe", "71e04ead-4cc7-416c-b9ca-60b635fdc50f", { @@ -210,7 +287,7 @@ describe("getResource()", () => { }, } ) - const latestRevision = await client.getResource( + const latestRevision = await drupal.getResource( "node--recipe", "71e04ead-4cc7-416c-b9ca-60b635fdc50f", { @@ -227,10 +304,10 @@ describe("getResource()", () => { }) test("throws an error for invalid revision", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupal(BASE_URL) await expect( - client.getResource( + drupal.getResource( "node--recipe", "71e04ead-4cc7-416c-b9ca-60b635fdc50f", { @@ -246,10 +323,10 @@ describe("getResource()", () => { }) test("throws an error if revision access is forbidden", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupal(BASE_URL) await expect( - client.getResource( + drupal.getResource( "node--recipe", "71e04ead-4cc7-416c-b9ca-60b635fdc50f", { @@ -265,10 +342,10 @@ describe("getResource()", () => { }) test("throws an error for invalid resource type", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupal(BASE_URL, { useDefaultEndpoints: false }) await expect( - client.getResource( + drupal.getResource( "RESOURCE-DOES-NOT-EXIST", "71e04ead-4cc7-416c-b9ca-60b635fdc50f" ) @@ -276,10 +353,10 @@ describe("getResource()", () => { }) test("throws an error for invalid params", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupal(BASE_URL) await expect( - client.getResource( + drupal.getResource( "node--recipe", "71e04ead-4cc7-416c-b9ca-60b635fdc50f", { @@ -294,27 +371,25 @@ describe("getResource()", () => { }) test("makes un-authenticated requests by default", async () => { - const client = new DrupalClient(BASE_URL) - const fetchSpy = jest.spyOn(client, "fetch") + const drupal = new NextDrupal(BASE_URL) + const drupalFetchSpy = jest.spyOn(drupal, "fetch") - await client.getResource( + await drupal.getResource( "node--recipe", "71e04ead-4cc7-416c-b9ca-60b635fdc50f" ) - expect(fetchSpy).toHaveBeenCalledWith(expect.anything(), { + expect(drupalFetchSpy).toHaveBeenCalledWith(expect.anything(), { withAuth: false, }) }) test("makes authenticated requests with withAuth option", async () => { - const client = new DrupalClient(BASE_URL, { - useDefaultResourceTypeEntry: true, + const drupal = new NextDrupal(BASE_URL, { auth: `Bearer sample-token`, }) const fetchSpy = spyOnFetch() - jest.spyOn(client, "getAccessToken") - await client.getResource( + await drupal.getResource( "node--recipe", "71e04ead-4cc7-416c-b9ca-60b635fdc50f", { @@ -322,31 +397,26 @@ describe("getResource()", () => { } ) - expect(fetchSpy).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: "Bearer sample-token", - }), - }) - ) + expect( + (fetchSpy.mock.lastCall[1].headers as Headers).get("Authorization") + ).toBe("Bearer sample-token") }) }) describe("getResourceByPath()", () => { test("fetches a resource by path", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupal(BASE_URL) await expect( - client.getResourceByPath("/recipes/deep-mediterranean-quiche") + drupal.getResourceByPath("/recipes/deep-mediterranean-quiche") ).resolves.toMatchSnapshot() }) test("fetches a resource by path with params", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupal(BASE_URL) await expect( - client.getResourceByPath("/recipes/deep-mediterranean-quiche", { + drupal.getResourceByPath("/recipes/deep-mediterranean-quiche", { params: { "fields[node--recipe]": "title,field_cooking_time", }, @@ -355,8 +425,8 @@ describe("getResourceByPath()", () => { }) test("fetches a resource by path using locale", async () => { - const client = new DrupalClient(BASE_URL) - const recipe = await client.getResourceByPath( + const drupal = new NextDrupal(BASE_URL) + const recipe = await drupal.getResourceByPath( "/recipes/quiche-mediterráneo-profundo", { locale: "es", @@ -371,18 +441,18 @@ describe("getResourceByPath()", () => { }) test("fetches raw data", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupal(BASE_URL, { useDefaultEndpoints: false }) await expect( - client.getResourceByPath("/recipes/deep-mediterranean-quiche", { + drupal.getResourceByPath("/recipes/deep-mediterranean-quiche", { deserialize: false, }) ).resolves.toMatchSnapshot() }) test("fetches a resource by revision", async () => { - const client = new DrupalClient(BASE_URL) - const recipe = await client.getResourceByPath( + const drupal = new NextDrupal(BASE_URL) + const recipe = await drupal.getResourceByPath( "/recipes/deep-mediterranean-quiche", { params: { @@ -390,7 +460,7 @@ describe("getResourceByPath()", () => { }, } ) - const latestRevision = await client.getResourceByPath( + const latestRevision = await drupal.getResourceByPath( "/recipes/deep-mediterranean-quiche", { params: { @@ -406,10 +476,10 @@ describe("getResourceByPath()", () => { }) test("throws an error for invalid revision", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupal(BASE_URL) await expect( - client.getResourceByPath( + drupal.getResourceByPath( "/recipes/deep-mediterranean-quiche", { params: { @@ -424,10 +494,10 @@ describe("getResourceByPath()", () => { }) test("throws an error if revision access is forbidden", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupal(BASE_URL) await expect( - client.getResourceByPath( + drupal.getResourceByPath( "/recipes/deep-mediterranean-quiche", { params: { @@ -442,18 +512,18 @@ describe("getResourceByPath()", () => { }) test("returns null for path not found", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupal(BASE_URL) await expect( - client.getResourceByPath("/path-do-not-exist") + drupal.getResourceByPath("/path-do-not-exist") ).rejects.toThrow("Unable to resolve path /path-do-not-exist.") }) test("throws an error for invalid params", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupal(BASE_URL) await expect( - client.getResourceByPath( + drupal.getResourceByPath( "/recipes/deep-mediterranean-quiche", { params: { @@ -467,14 +537,14 @@ describe("getResourceByPath()", () => { }) test("makes un-authenticated requests by default", async () => { - const client = new DrupalClient(BASE_URL) - const fetchSpy = jest.spyOn(client, "fetch") - const getAccessTokenSpy = jest.spyOn(client, "getAccessToken") + const drupal = new NextDrupal(BASE_URL) + const drupalFetchSpy = jest.spyOn(drupal, "fetch") + const getAccessTokenSpy = jest.spyOn(drupal, "getAccessToken") - await client.getResourceByPath( + await drupal.getResourceByPath( "/recipes/deep-mediterranean-quiche" ) - expect(fetchSpy).toHaveBeenCalledWith( + expect(drupalFetchSpy).toHaveBeenCalledWith( expect.anything(), expect.not.objectContaining({ headers: expect.objectContaining({ @@ -486,45 +556,47 @@ describe("getResourceByPath()", () => { }) test("makes authenticated requests with withAuth", async () => { - const client = new DrupalClient(BASE_URL, { + const drupal = new NextDrupal(BASE_URL, { auth: mocks.auth.clientIdSecret, + throwJsonApiErrors: false, + logger: mockLogger(), + }) + const fetchSpy = spyOnFetch({ + responseBody: { "resolvedResource#uri{0}": { body: "{}" } }, + status: 207, }) - const fetchSpy = spyOnFetch() const getAccessTokenSpy = jest - .spyOn(client, "getAccessToken") + .spyOn(drupal, "getAccessToken") .mockImplementation(async () => mocks.auth.accessToken) - await client.getResourceByPath( + await drupal.getResourceByPath( "/recipes/deep-mediterranean-quiche", { withAuth: true, } ) - expect(fetchSpy).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: `${mocks.auth.accessToken.token_type} ${mocks.auth.accessToken.access_token}`, - }), - }) + expect( + (fetchSpy.mock.lastCall[1].headers as Headers).get("Authorization") + ).toBe( + `${mocks.auth.accessToken.token_type} ${mocks.auth.accessToken.access_token}` ) expect(getAccessTokenSpy).toHaveBeenCalled() }) test("returns null if path is falsey", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupal(BASE_URL) - const resource = await client.getResourceByPath("") + const resource = await drupal.getResourceByPath("") expect(resource).toBe(null) }) }) describe("getResourceCollection()", () => { test("fetches a resource collection", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupal(BASE_URL) - const articles = await client.getResourceCollection("node--article", { + const articles = await drupal.getResourceCollection("node--article", { params: { "fields[node--article]": "title", }, @@ -534,9 +606,9 @@ describe("getResourceCollection()", () => { }) test("fetches a resource collection using locale", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupal(BASE_URL) - const articles = await client.getResourceCollection("node--article", { + const articles = await drupal.getResourceCollection("node--article", { locale: "es", defaultLocale: "en", params: { @@ -550,9 +622,9 @@ describe("getResourceCollection()", () => { }) test("fetches raw data", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupal(BASE_URL, { useDefaultEndpoints: false }) - const recipes = await client.getResourceCollection("node--recipe", { + const recipes = await drupal.getResourceCollection("node--recipe", { deserialize: false, params: { "fields[node--recipe]": "title", @@ -564,18 +636,18 @@ describe("getResourceCollection()", () => { }) test("throws an error for invalid resource type", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupal(BASE_URL, { useDefaultEndpoints: false }) await expect( - client.getResourceCollection("RESOURCE-DOES-NOT-EXIST") + drupal.getResourceCollection("RESOURCE-DOES-NOT-EXIST") ).rejects.toThrow("Resource of type 'RESOURCE-DOES-NOT-EXIST' not found.") }) test("throws an error for invalid params", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupal(BASE_URL) await expect( - client.getResourceCollection("node--recipe", { + drupal.getResourceCollection("node--recipe", { params: { include: "invalid_relationship", }, @@ -586,43 +658,36 @@ describe("getResourceCollection()", () => { }) test("makes un-authenticated requests by default", async () => { - const client = new DrupalClient(BASE_URL) - const fetchSpy = jest.spyOn(client, "fetch") + const drupal = new NextDrupal(BASE_URL) + const drupalFetchSpy = jest.spyOn(drupal, "fetch") - await client.getResourceCollection("node--recipe") - expect(fetchSpy).toHaveBeenCalledWith(expect.anything(), { + await drupal.getResourceCollection("node--recipe") + expect(drupalFetchSpy).toHaveBeenCalledWith(expect.anything(), { withAuth: false, }) }) test("makes authenticated requests with withAuth option", async () => { - const client = new DrupalClient(BASE_URL, { - useDefaultResourceTypeEntry: true, + const drupal = new NextDrupal(BASE_URL, { auth: `Bearer sample-token`, }) const fetchSpy = spyOnFetch() - jest.spyOn(client, "getAccessToken") - await client.getResourceCollection("node--recipe", { + await drupal.getResourceCollection("node--recipe", { withAuth: true, }) - expect(fetchSpy).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: "Bearer sample-token", - }), - }) - ) + expect( + (fetchSpy.mock.lastCall[1].headers as Headers).get("Authorization") + ).toBe("Bearer sample-token") }) }) describe("getSearchIndex()", () => { test("fetches a search index", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupal(BASE_URL) - const search = await client.getSearchIndex("recipes", { + const search = await drupal.getSearchIndex("recipes", { params: { "fields[node--recipe]": "title", }, @@ -632,9 +697,9 @@ describe("getSearchIndex()", () => { }) test("fetches a search index with locale", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupal(BASE_URL) - const search = await client.getSearchIndex("recipes", { + const search = await drupal.getSearchIndex("recipes", { locale: "es", defaultLocale: "en", params: { @@ -646,9 +711,9 @@ describe("getSearchIndex()", () => { }) test("fetches a search index with facets filters", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupal(BASE_URL) - const search = await client.getSearchIndex( + const search = await drupal.getSearchIndex( "recipes", { deserialize: false, @@ -664,9 +729,9 @@ describe("getSearchIndex()", () => { }) test("fetches raw data from search index", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupal(BASE_URL) - const search = await client.getSearchIndex("recipes", { + const search = await drupal.getSearchIndex("recipes", { deserialize: false, params: { "filter[difficulty]": "easy", @@ -678,60 +743,53 @@ describe("getSearchIndex()", () => { }) test("makes un-authenticated requests by default", async () => { - const client = new DrupalClient(BASE_URL) - const fetchSpy = jest.spyOn(client, "fetch") + const drupal = new NextDrupal(BASE_URL) + const drupalFetchSpy = jest.spyOn(drupal, "fetch") - await client.getSearchIndex("recipes") + await drupal.getSearchIndex("recipes") - expect(fetchSpy).toHaveBeenCalledWith(expect.anything(), { + expect(drupalFetchSpy).toHaveBeenCalledWith(expect.anything(), { withAuth: false, }) }) test("throws an error for invalid index", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupal(BASE_URL) - await expect(client.getSearchIndex("INVALID-INDEX")).rejects.toThrow( + await expect(drupal.getSearchIndex("INVALID-INDEX")).rejects.toThrow( "Not Found" ) }) test("makes authenticated requests with withAuth option", async () => { - const client = new DrupalClient(BASE_URL, { - useDefaultResourceTypeEntry: true, + const drupal = new NextDrupal(BASE_URL, { auth: `Bearer sample-token`, }) const fetchSpy = spyOnFetch() - jest.spyOn(client, "getAccessToken") - await client.getSearchIndex("recipes", { + await drupal.getSearchIndex("recipes", { withAuth: true, }) - expect(fetchSpy).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: "Bearer sample-token", - }), - }) - ) + expect( + (fetchSpy.mock.lastCall[1].headers as Headers).get("Authorization") + ).toBe("Bearer sample-token") }) }) describe("getView()", () => { test("fetches a view", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupal(BASE_URL) - const view = await client.getView("featured_articles--page_1") + const view = await drupal.getView("featured_articles--page_1") expect(view).toMatchSnapshot() }) test("fetches a view with params", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupal(BASE_URL) - const view = await client.getView("featured_articles--page_1", { + const view = await drupal.getView("featured_articles--page_1", { params: { "fields[node--article]": "title", }, @@ -741,9 +799,9 @@ describe("getView()", () => { }) test("fetches a view with locale", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupal(BASE_URL) - const view = await client.getView("featured_articles--page_1", { + const view = await drupal.getView("featured_articles--page_1", { locale: "es", defaultLocale: "en", params: { @@ -755,9 +813,9 @@ describe("getView()", () => { }) test("fetches raw data", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupal(BASE_URL) - const view = await client.getView("featured_articles--page_1", { + const view = await drupal.getView("featured_articles--page_1", { locale: "es", defaultLocale: "en", deserialize: false, @@ -770,44 +828,37 @@ describe("getView()", () => { }) test("throws an error for invalid view name", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupal(BASE_URL) - await expect(client.getView("INVALID")).rejects.toThrow("Not Found") + await expect(drupal.getView("INVALID")).rejects.toThrow("Not Found") }) test("makes un-authenticated requests by default", async () => { - const client = new DrupalClient(BASE_URL) - const fetchSpy = jest.spyOn(client, "fetch") + const drupal = new NextDrupal(BASE_URL) + const drupalFetchSpy = jest.spyOn(drupal, "fetch") - await client.getView("featured_articles--page_1") - expect(fetchSpy).toHaveBeenCalledWith(expect.anything(), { + await drupal.getView("featured_articles--page_1") + expect(drupalFetchSpy).toHaveBeenCalledWith(expect.anything(), { withAuth: false, }) }) test("makes authenticated requests with withAuth option", async () => { - const client = new DrupalClient(BASE_URL, { - useDefaultResourceTypeEntry: true, + const drupal = new NextDrupal(BASE_URL, { auth: `Bearer sample-token`, }) const fetchSpy = spyOnFetch() - jest.spyOn(client, "getAccessToken") - await client.getView("featured_articles--page_1", { withAuth: true }) + await drupal.getView("featured_articles--page_1", { withAuth: true }) - expect(fetchSpy).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: "Bearer sample-token", - }), - }) - ) + expect( + (fetchSpy.mock.lastCall[1].headers as Headers).get("Authorization") + ).toBe("Bearer sample-token") }) test("fetches a view with links for pagination", async () => { - const client = new DrupalClient(BASE_URL) - const view = await client.getView("recipes--page_1") + const drupal = new NextDrupal(BASE_URL) + const view = await drupal.getView("recipes--page_1") expect(view.links).toHaveProperty("next") }) @@ -815,13 +866,13 @@ describe("getView()", () => { describe("translatePath()", () => { test("translates a path", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupal(BASE_URL) - const path = await client.translatePath("recipes/deep-mediterranean-quiche") + const path = await drupal.translatePath("recipes/deep-mediterranean-quiche") expect(path).toMatchSnapshot() - const path2 = await client.translatePath( + const path2 = await drupal.translatePath( "/recipes/deep-mediterranean-quiche" ) @@ -829,20 +880,20 @@ describe("translatePath()", () => { }) test("returns null for path not found", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupal(BASE_URL) - const path = await client.translatePath("/path-not-found") + const path = await drupal.translatePath("/path-not-found") expect(path).toBeNull() }) test("makes un-authenticated requests by default", async () => { - const client = new DrupalClient(BASE_URL) - const fetchSpy = jest.spyOn(client, "fetch") + const drupal = new NextDrupal(BASE_URL) + const drupalFetchSpy = jest.spyOn(drupal, "fetch") - await client.translatePath("recipes/deep-mediterranean-quiche") + await drupal.translatePath("recipes/deep-mediterranean-quiche") - expect(fetchSpy).toHaveBeenCalledWith( + expect(drupalFetchSpy).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ withAuth: false, @@ -851,24 +902,17 @@ describe("translatePath()", () => { }) test("makes authenticated requests with withAuth option", async () => { - const client = new DrupalClient(BASE_URL, { - useDefaultResourceTypeEntry: true, + const drupal = new NextDrupal(BASE_URL, { auth: `Bearer sample-token`, }) const fetchSpy = spyOnFetch() - jest.spyOn(client, "getAccessToken") - await client.translatePath("recipes/deep-mediterranean-quiche", { + await drupal.translatePath("recipes/deep-mediterranean-quiche", { withAuth: true, }) - expect(fetchSpy).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: "Bearer sample-token", - }), - }) - ) + expect( + (fetchSpy.mock.lastCall[1].headers as Headers).get("Authorization") + ).toBe("Bearer sample-token") }) }) diff --git a/packages/next-drupal/tests/NextDrupalBase/auth-functions.test.ts b/packages/next-drupal/tests/NextDrupalBase/auth-functions.test.ts new file mode 100644 index 00000000..a460ab7c --- /dev/null +++ b/packages/next-drupal/tests/NextDrupalBase/auth-functions.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, test } from "@jest/globals" +import { isAccessTokenAuth, isBasicAuth, isClientIdSecretAuth } from "../../src" +import { mocks } from "../utils" + +const { accessToken, basicAuth, clientIdSecret } = mocks.auth + +describe("isBasicAuth", () => { + test("returns false if username is undefined", () => { + expect( + isBasicAuth( + // @ts-expect-error + { password: basicAuth.password } + ) + ).toBe(false) + }) + + test("returns false if password is undefined", () => { + expect( + isBasicAuth( + // @ts-expect-error + { username: basicAuth.username } + ) + ).toBe(false) + }) + + test("returns true if username and password are given", () => { + expect(isBasicAuth(basicAuth)).toBe(true) + }) +}) + +describe("isAccessTokenAuth", () => { + test("returns false if access_token is undefined", () => { + expect( + isAccessTokenAuth( + // @ts-expect-error + { token_type: accessToken.token_type } + ) + ).toBe(false) + }) + + test("returns false if token_type is undefined", () => { + expect( + isAccessTokenAuth( + // @ts-expect-error + { access_token: accessToken.access_token } + ) + ).toBe(false) + }) + + test("returns true if access_token and token_type are given", () => { + expect(isAccessTokenAuth(accessToken)).toBe(true) + }) +}) + +describe("isClientIdSecretAuth", () => { + test("returns false if clientId is undefined", () => { + expect( + isClientIdSecretAuth( + // @ts-expect-error + { clientSecret: clientIdSecret.clientSecret } + ) + ).toBe(false) + }) + + test("returns false if clientSecret is undefined", () => { + expect( + isClientIdSecretAuth( + // @ts-expect-error + { clientId: clientIdSecret.clientId } + ) + ).toBe(false) + }) + + test("returns true if clientId and clientSecret are given", () => { + expect(isClientIdSecretAuth(clientIdSecret)).toBe(true) + }) +}) diff --git a/packages/next-drupal/tests/NextDrupalBase/basic-methods.test.ts b/packages/next-drupal/tests/NextDrupalBase/basic-methods.test.ts new file mode 100644 index 00000000..699d146c --- /dev/null +++ b/packages/next-drupal/tests/NextDrupalBase/basic-methods.test.ts @@ -0,0 +1,497 @@ +import { afterEach, describe, expect, jest, test } from "@jest/globals" +import { JsonApiErrors, NextDrupalBase } from "../../src" +import { BASE_URL, mockLogger, spyOnFetch, spyOnFetchOnce } from "../utils" +import type { JsonApiError } from "../../src" + +jest.setTimeout(10000) + +afterEach(() => { + jest.restoreAllMocks() +}) + +describe("addLocalePrefix()", () => { + const drupal = new NextDrupalBase(BASE_URL) + + test('returns path with leading "/"', () => { + expect(drupal.addLocalePrefix("foo")).toBe("/foo") + }) + + test("returns path when using default locale", () => { + expect( + drupal.addLocalePrefix("/foo", { + locale: "es", + defaultLocale: "es", + }) + ).toBe("/foo") + }) + + test("returns path when using non-default locale", () => { + expect( + drupal.addLocalePrefix("/foo", { + locale: "es", + defaultLocale: "en", + }) + ).toBe("/es/foo") + }) +}) + +describe("buildUrl()", () => { + const drupal = new NextDrupalBase(BASE_URL) + + test("returns a URL object", () => { + const url = drupal.buildUrl("") + expect(url).toBeInstanceOf(URL) + expect(url.toString()).toEqual(`${BASE_URL}/`) + }) + + test("adds baseURL if given a relative path", () => { + expect(drupal.buildUrl("/foo").toString()).toEqual(`${BASE_URL}/foo`) + }) + + test("does not add baseURL if path includes hostname", () => { + const path = "https://example.com/foo" + expect(drupal.buildUrl(path).toString()).toEqual(path) + }) + + test("adds the searchParams", () => { + expect(drupal.buildUrl("/foo", { bar: "baz" }).toString()).toEqual( + `${BASE_URL}/foo?bar=baz` + ) + + expect( + drupal + .buildUrl("/jsonapi/node/article", { + sort: "-created", + "fields[node--article]": "title,path", + }) + .toString() + ).toEqual( + `${BASE_URL}/jsonapi/node/article?sort=-created&fields%5Bnode--article%5D=title%2Cpath` + ) + }) + + test("adds the searchParams from a DrupalJsonApiParams", () => { + const searchParams = { + getQueryObject: () => ({ + sort: "-created", + "fields[node--article]": "title,path", + }), + } + + expect( + drupal.buildUrl("/jsonapi/node/article", searchParams).toString() + ).toEqual( + `${BASE_URL}/jsonapi/node/article?sort=-created&fields%5Bnode--article%5D=title%2Cpath` + ) + }) + + test("does not throw an error when searchParams is null", () => { + expect(() => + drupal.buildUrl("/some-path", null).toString() + ).not.toThrowError( + "Cannot use 'in' operator to search for 'getQueryObject' in null" + ) + }) +}) + +describe("buildEndpoint()", () => { + const drupal = new NextDrupalBase(BASE_URL) + + test("returns a URL string", async () => { + const endpoint = await drupal.buildEndpoint() + + expect(typeof endpoint).toBe("string") + }) + + test("adds the apiPrefix", async () => { + const drupal = new NextDrupalBase(BASE_URL, { apiPrefix: "/someapi" }) + + const endpoint = await drupal.buildEndpoint() + + expect(endpoint).toBe(`${BASE_URL}/someapi`) + }) + + test("adds the path", async () => { + const endpoint = await drupal.buildEndpoint({ path: "/some-path" }) + + expect(endpoint).toBe(`${BASE_URL}/some-path`) + }) + + test('ensures the path starts with "/"', async () => { + const endpoint = await drupal.buildEndpoint({ + path: "some-path", + }) + + expect(endpoint).toBe(`${BASE_URL}/some-path`) + }) + + test("adds the search params", async () => { + const endpoint = await drupal.buildEndpoint({ + path: "/zoo", + searchParams: { animal: "unicorn" }, + }) + + expect(endpoint).toBe(`${BASE_URL}/zoo?animal=unicorn`) + }) + + test("adds locale then apiPrefix then path", async () => { + const drupal = new NextDrupalBase(BASE_URL, { apiPrefix: "/someapi" }) + const endpoint = await drupal.buildEndpoint({ + locale: "en", + path: "/zoo", + searchParams: { animal: "unicorn" }, + }) + + expect(endpoint).toBe(`${BASE_URL}/en/someapi/zoo?animal=unicorn`) + }) +}) + +describe("constructPathFromSegment()", () => { + const frontPage = "/home" + const drupal = new NextDrupalBase(BASE_URL, { frontPage }) + + describe("with no options", () => { + test("returns homepage given no segments", () => { + expect(drupal.constructPathFromSegment(undefined)).toBe(frontPage) + expect(drupal.constructPathFromSegment("")).toBe(frontPage) + expect(drupal.constructPathFromSegment([])).toBe(frontPage) + expect(drupal.constructPathFromSegment([""])).toBe(frontPage) + }) + + test("returns path given string", () => { + expect(drupal.constructPathFromSegment("foo")).toBe("/foo") + }) + + test("returns path given array", () => { + expect(drupal.constructPathFromSegment(["foo"])).toBe("/foo") + + expect(drupal.constructPathFromSegment(["foo", "bar"])).toBe("/foo/bar") + }) + + test("encodes path with punctuation", async () => { + expect( + drupal.constructPathFromSegment(["path&with", "^punc&", "in$path"]) + ).toEqual("/path%26with/%5Epunc%26/in%24path") + }) + + test("prevents path from ending in slash", () => { + expect(drupal.constructPathFromSegment(["foo", ""])).toBe("/foo") + }) + }) + + describe("with locale options", () => { + test("returns path when using default locale", () => { + expect( + drupal.constructPathFromSegment(["foo"], { + locale: "es", + defaultLocale: "es", + }) + ).toBe("/foo") + }) + + test("returns path when using non-default locale", () => { + expect( + drupal.constructPathFromSegment(["foo"], { + locale: "es", + defaultLocale: "en", + }) + ).toBe("/es/foo") + }) + }) + + describe("with pathPrefix option", () => { + const pathPrefix = "/prefix" + const options = { + pathPrefix, + } + + test("returns path with prefix", () => { + expect(drupal.constructPathFromSegment(["foo"], options)).toBe( + `${pathPrefix}/foo` + ) + }) + + test('returns correct path given "/" prefix', () => { + expect( + drupal.constructPathFromSegment(["foo"], { pathPrefix: "/" }) + ).toBe("/foo") + }) + + test('returns correct path given prefix not starting in "/"', () => { + expect( + drupal.constructPathFromSegment(["foo"], { pathPrefix: "prefix" }) + ).toBe("/prefix/foo") + }) + + test('returns correct path given prefix ending in "/"', () => { + expect( + drupal.constructPathFromSegment(["foo"], { pathPrefix: "/prefix/" }) + ).toBe("/prefix/foo") + }) + + test("returns pathPrefix given no segments", () => { + expect(drupal.constructPathFromSegment(undefined, options)).toBe( + pathPrefix + ) + expect(drupal.constructPathFromSegment("", options)).toBe(pathPrefix) + expect(drupal.constructPathFromSegment([], options)).toBe(pathPrefix) + expect(drupal.constructPathFromSegment([""], options)).toBe(pathPrefix) + }) + }) +}) + +describe("debug()", () => { + test("does not print messages by default", () => { + const logger = mockLogger() + const drupal = new NextDrupalBase(BASE_URL, { logger }) + const message = "Example message" + drupal.debug(message) + expect(logger.debug).not.toHaveBeenCalled() + }) + + test("prints messages when debugging on", () => { + const logger = mockLogger() + const drupal = new NextDrupalBase(BASE_URL, { logger, debug: true }) + const message = "Example message" + drupal.debug(message) + expect(logger.debug).toHaveBeenCalledWith("Debug mode is on.") + expect(logger.debug).toHaveBeenCalledWith(message) + }) +}) + +describe("getErrorsFromResponse()", () => { + const drupal = new NextDrupalBase(BASE_URL) + + test("returns application/json error message", async () => { + const message = "An error occurred." + const response = new Response(JSON.stringify({ message }), { + status: 403, + headers: { + "content-type": "application/json", + }, + }) + + expect(await drupal.getErrorsFromResponse(response)).toBe(message) + }) + + test("returns application/vnd.api+json errors", async () => { + const payload = { + errors: [ + { + status: "404", + title: "Not found", + detail: "Oops.", + }, + { + status: "418", + title: "I am a teapot", + detail: "Even RFCs have easter eggs.", + }, + ], + } + const response = new Response(JSON.stringify(payload), { + status: 403, + headers: { + "content-type": "application/vnd.api+json", + }, + }) + + expect(await drupal.getErrorsFromResponse(response)).toMatchObject( + payload.errors + ) + }) + + test("returns the response status text if the application/vnd.api+json errors cannot be found", async () => { + const payload = { + contains: 'no "errors" entry', + } + const response = new Response(JSON.stringify(payload), { + status: 418, + statusText: "I'm a Teapot", + headers: { + "content-type": "application/vnd.api+json", + }, + }) + + expect(await drupal.getErrorsFromResponse(response)).toBe("I'm a Teapot") + }) + + test("returns the response status text if no errors can be found", async () => { + const response = new Response(JSON.stringify({}), { + status: 403, + statusText: "Forbidden", + }) + + expect(await drupal.getErrorsFromResponse(response)).toBe("Forbidden") + }) +}) + +describe("throwIfJsonErrors()", () => { + const drupal = new NextDrupalBase(BASE_URL) + + test("does not throw if response is ok", async () => { + expect.assertions(1) + + const response = new Response(JSON.stringify({})) + + await expect(drupal.throwIfJsonErrors(response)).resolves.toBe(undefined) + }) + + test("throws a JsonApiErrors object", async () => { + expect.assertions(1) + + const payload = { + errors: [ + { + status: "404", + title: "Not found", + detail: "Oops.", + }, + { + status: "418", + title: "I am a teapot", + detail: "Even RFCs have easter eggs.", + }, + ] as JsonApiError[], + } + const status = 403 + const response = new Response(JSON.stringify(payload), { + status, + headers: { + "content-type": "application/vnd.api+json", + }, + }) + + const expectedError = new JsonApiErrors(payload.errors, status) + await expect(drupal.throwIfJsonErrors(response)).rejects.toEqual( + expectedError + ) + }) + + test("adds an error prefix", async () => { + expect.assertions(1) + + const payload = { + errors: [ + { + status: "418", + title: "I am a teapot", + detail: "Even RFCs have easter eggs.", + }, + ] as JsonApiError[], + } + const status = 403 + const response = new Response(JSON.stringify(payload), { + status, + headers: { + "content-type": "application/vnd.api+json", + }, + }) + + const messagePrefix = "Optional error message prefix:" + const expectedError = new JsonApiErrors(payload.errors, status) + expectedError.message = `${messagePrefix} ${expectedError.message}` + + await expect( + drupal.throwIfJsonErrors(response, messagePrefix) + ).rejects.toEqual(expectedError) + }) +}) + +describe("validateDraftUrl()", () => { + const drupal = new NextDrupalBase(BASE_URL) + + test("outputs debug messages", async () => { + const logger = mockLogger() + const drupal = new NextDrupalBase(BASE_URL, { + debug: true, + logger, + }) + const path = "/example" + const searchParams = new URLSearchParams({ + path, + }) + const testPayload = { test: "resolved" } + + spyOnFetchOnce({ + responseBody: testPayload, + }) + spyOnFetchOnce({ + responseBody: { + message: "fail", + }, + status: 404, + }) + + let response = await drupal.validateDraftUrl(searchParams) + + expect(response.status).toBe(200) + expect(logger.debug).toHaveBeenCalledWith("Debug mode is on.") + expect(logger.debug).toHaveBeenCalledWith( + `Fetching draft url validation for ${path}.` + ) + expect(logger.debug).toHaveBeenCalledWith(`Validated path, ${path}`) + + response = await drupal.validateDraftUrl(searchParams) + + expect(response.status).toBe(404) + expect(logger.debug).toHaveBeenCalledWith( + `Could not validate path, ${path}` + ) + }) + + test("calls draft-url endpoint", async () => { + const searchParams = new URLSearchParams({ + path: "/example", + }) + + const testPayload = { test: "resolved" } + const fetchSpy = spyOnFetch({ responseBody: testPayload }) + + await drupal.validateDraftUrl(searchParams) + + expect(fetchSpy.mock.calls[0][0]).toBe(`${BASE_URL}/next/draft-url`) + expect(fetchSpy.mock.calls[0][1]).toMatchObject({ + method: "POST", + body: JSON.stringify(Object.fromEntries(searchParams.entries())), + }) + expect( + Object.fromEntries( + (fetchSpy.mock.calls[0][1].headers as Headers).entries() + ) + ).toMatchObject({ + accept: "application/vnd.api+json", + "content-type": "application/json", + }) + }) + + test("returns a response object on success", async () => { + const searchParams = new URLSearchParams({ + path: "/example", + }) + + const testPayload = { test: "resolved" } + spyOnFetch({ responseBody: testPayload }) + + const response = await drupal.validateDraftUrl(searchParams) + + expect(response.ok).toBe(true) + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject(testPayload) + }) + + test("returns a response if fetch throws", async () => { + const searchParams = new URLSearchParams({ + path: "/example", + }) + + const message = "random fetch error" + spyOnFetch({ throwErrorMessage: message }) + + const response = await drupal.validateDraftUrl(searchParams) + + expect(response.ok).toBe(false) + expect(response.status).toBe(401) + expect(await response.json()).toMatchObject({ message }) + }) +}) diff --git a/packages/next-drupal/tests/NextDrupalBase/constructor.test.ts b/packages/next-drupal/tests/NextDrupalBase/constructor.test.ts new file mode 100644 index 00000000..7bd450d3 --- /dev/null +++ b/packages/next-drupal/tests/NextDrupalBase/constructor.test.ts @@ -0,0 +1,218 @@ +import { afterEach, describe, expect, jest, test } from "@jest/globals" +import { NextDrupalBase } from "../../src" +import { DEBUG_MESSAGE_PREFIX, logger as defaultLogger } from "../../src/logger" +import { BASE_URL, mocks } from "../utils" +import type { NextDrupalAuth, Logger } from "../../src" + +afterEach(() => { + jest.restoreAllMocks() +}) + +describe("baseUrl parameter", () => { + const env = process.env + + beforeEach(() => { + jest.resetModules() + process.env = { ...env } + }) + + afterEach(() => { + process.env = env + }) + + test("throws error given an invalid baseUrl", () => { + // @ts-ignore + expect(() => new NextDrupalBase()).toThrow( + "The 'baseUrl' param is required." + ) + + // @ts-ignore + expect(() => new NextDrupalBase({})).toThrow( + "The 'baseUrl' param is required." + ) + }) + + test("announces debug mode when turned on", () => { + const consoleSpy = jest.spyOn(console, "debug").mockImplementation(() => { + // + }) + + new NextDrupalBase(BASE_URL, { + debug: true, + }) + + expect(consoleSpy).toHaveBeenCalledWith( + DEBUG_MESSAGE_PREFIX, + "Debug mode is on." + ) + }) + + test("returns a NextDrupalBase", () => { + expect(new NextDrupalBase(BASE_URL)).toBeInstanceOf(NextDrupalBase) + }) +}) + +describe("options parameter", () => { + describe("accessToken", () => { + test("defaults to `undefined`", () => { + const drupal = new NextDrupalBase(BASE_URL) + expect(drupal.accessToken).toBe(undefined) + }) + + test("sets the accessToken", async () => { + const accessToken = mocks.auth.accessToken + + const drupal = new NextDrupalBase(BASE_URL, { + accessToken, + }) + + expect(drupal.accessToken).toEqual(accessToken) + }) + }) + + describe("apiPrefix", () => { + test("defaults to empty string", () => { + const drupal = new NextDrupalBase(BASE_URL) + expect(drupal.apiPrefix).toBe("") + }) + + test("sets the apiPrefix", () => { + const customEndPoint = "/customapi" + const drupal = new NextDrupalBase(BASE_URL, { + apiPrefix: customEndPoint, + }) + expect(drupal.apiPrefix).toBe(customEndPoint) + }) + }) + + describe("auth", () => { + test("defaults to `undefined`", () => { + const drupal = new NextDrupalBase(BASE_URL) + expect(drupal.auth).toBe(undefined) + }) + + test("sets the auth credentials", () => { + const auth: NextDrupalAuth = { + username: "example", + password: "pw", + } + const drupal = new NextDrupalBase(BASE_URL, { + auth, + }) + expect(drupal.auth).toMatchObject({ + ...auth, + }) + }) + }) + + describe("debug", () => { + test("defaults to `false`", () => { + const consoleSpy = jest.spyOn(console, "debug").mockImplementation(() => { + // + }) + + new NextDrupalBase(BASE_URL) + + expect(consoleSpy).toBeCalledTimes(0) + }) + + test("turns on debug mode", () => { + const consoleSpy = jest.spyOn(console, "debug").mockImplementation(() => { + // + }) + + new NextDrupalBase(BASE_URL, { debug: true }) + + expect(consoleSpy).toBeCalledTimes(1) + }) + }) + + describe("fetcher", () => { + test("defaults to `undefined`", () => { + const drupal = new NextDrupalBase(BASE_URL) + expect(drupal.fetcher).toBe(undefined) + }) + + test("sets up a custom fetcher", () => { + const customFetcher: NextDrupalBase["fetcher"] = async () => { + // + } + const drupal = new NextDrupalBase(BASE_URL, { + fetcher: customFetcher, + }) + expect(drupal.fetcher).toBe(customFetcher) + }) + }) + + describe("headers", () => { + test("defaults to `Content-Type`/`Accept`", () => { + const drupal = new NextDrupalBase(BASE_URL) + expect(Object.fromEntries(drupal.headers.entries())).toMatchObject({ + "content-type": "application/json", + accept: "application/json", + }) + }) + + test("sets custom headers", () => { + const customHeaders = { + CustomContentType: "application/vnd.api+json", + CustomAccept: "application/vnd.api+json", + } + const expectedHeaders = {} + Object.keys(customHeaders).forEach((header) => { + expectedHeaders[header.toLowerCase()] = customHeaders[header] + }) + + const drupal = new NextDrupalBase(BASE_URL, { + headers: customHeaders, + }) + + expect(Object.fromEntries(drupal.headers.entries())).toMatchObject( + expectedHeaders + ) + }) + }) + + describe("logger", () => { + test("defaults to `console`-based `Logger`", () => { + const drupal = new NextDrupalBase(BASE_URL) + expect(drupal.logger).toBe(defaultLogger) + }) + + test("sets up a custom logger", () => { + const customLogger: Logger = { + log: () => { + // + }, + debug: () => { + // + }, + warn: () => { + // + }, + error: () => { + // + }, + } + + const drupal = new NextDrupalBase(BASE_URL, { + logger: customLogger, + }) + expect(drupal.logger).toBe(customLogger) + }) + }) + + describe("withAuth", () => { + test("defaults to `false`", () => { + const drupal = new NextDrupalBase(BASE_URL) + expect(drupal.withAuth).toBe(false) + }) + + test("can be set to `true`", () => { + const drupal = new NextDrupalBase(BASE_URL, { + withAuth: true, + }) + expect(drupal.withAuth).toBe(true) + }) + }) +}) diff --git a/packages/next-drupal/tests/NextDrupalBase/fetch-methods.test.ts b/packages/next-drupal/tests/NextDrupalBase/fetch-methods.test.ts new file mode 100644 index 00000000..ff443773 --- /dev/null +++ b/packages/next-drupal/tests/NextDrupalBase/fetch-methods.test.ts @@ -0,0 +1,573 @@ +import { afterEach, describe, expect, jest, test } from "@jest/globals" +import { FetchOptions, NextDrupalBase } from "../../src" +import { + BASE_URL, + mockLogger, + mocks, + spyOnFetch, + spyOnFetchOnce, +} from "../utils" +import type { + AccessToken, + NextDrupalAuth, + NextDrupalBaseOptions, +} from "../../src" + +afterEach(() => { + jest.restoreAllMocks() +}) + +describe("fetch()", () => { + const headers = { + "Content-Type": "application/json", + Accept: "application/json", + } + const defaultInit = { + credentials: "include", + headers: new Headers(headers), + } + const mockUrl = "https://example.com/mock-url" + const authHeader = mocks.auth.customAuthenticationHeader + + test("uses global fetch by default", async () => { + const logger = mockLogger() + const drupal = new NextDrupalBase(BASE_URL, { + debug: true, + logger, + }) + const mockResponseBody = { success: true } + const mockUrl = "https://example.com/mock-url" + const mockInit = { + priority: "high", + } as FetchOptions + const fetchSpy = spyOnFetch({ responseBody: mockResponseBody, headers }) + + const response = await drupal.fetch(mockUrl, mockInit) + + expect(fetchSpy).toBeCalledTimes(1) + expect(fetchSpy).toBeCalledWith( + mockUrl, + expect.objectContaining({ + ...defaultInit, + ...mockInit, + }) + ) + expect(response.headers.get("content-type")).toEqual("application/json") + expect(await response.json()).toMatchObject(mockResponseBody) + expect(logger.debug).toHaveBeenLastCalledWith( + `Using default fetch, fetching: ${mockUrl}` + ) + }) + + test("allows for custom fetcher", async () => { + const logger = mockLogger() + const customFetch = jest.fn() + + const drupal = new NextDrupalBase(BASE_URL, { + fetcher: customFetch, + debug: true, + logger, + }) + const mockUrl = "https://example.com/mock-url" + const mockInit = { + priority: "high", + } as FetchOptions + + await drupal.fetch(mockUrl, mockInit) + + expect(customFetch).toBeCalledTimes(1) + expect(customFetch).toHaveBeenCalledWith( + mockUrl, + expect.objectContaining({ + ...mockInit, + ...defaultInit, + }) + ) + expect(logger.debug).toHaveBeenLastCalledWith( + `Using custom fetcher, fetching: ${mockUrl}` + ) + }) + + test("handles relative URLs", async () => { + const drupal = new NextDrupalBase(BASE_URL) + const mockResponseBody = { success: true } + const mockUrl = "/mock-url" + const fetchSpy = spyOnFetch({ responseBody: mockResponseBody, headers }) + + await drupal.fetch(mockUrl) + + expect(fetchSpy).toBeCalledTimes(1) + expect(fetchSpy).toBeCalledWith( + `${BASE_URL}${mockUrl}`, + expect.objectContaining({ + ...defaultInit, + }) + ) + }) + + test("allows setting custom headers", async () => { + const customFetch = jest.fn() + const constructorHeaders = { + constructor: "header", + Accept: "application/set-from-constructor", + } + const paramHeaders = { + params: "header", + Accept: "application/set-from-params", + } + const drupal = new NextDrupalBase(BASE_URL, { + fetcher: customFetch, + headers: constructorHeaders, + }) + + const url = "http://example.com" + + await drupal.fetch(url, { + headers: paramHeaders, + }) + + expect(customFetch).toHaveBeenLastCalledWith( + url, + expect.objectContaining({ + ...defaultInit, + headers: new Headers({ + ...constructorHeaders, + ...paramHeaders, + }), + }) + ) + }) + + test("does not add Authorization header by default", async () => { + const fetcher: NextDrupalBaseOptions["fetcher"] = jest.fn() + const drupal = new NextDrupalBase(BASE_URL, { + auth: authHeader, + fetcher, + }) + + await drupal.fetch(mockUrl) + + expect(fetcher.mock.lastCall[0]).toBe(mockUrl) + expect(fetcher.mock.lastCall[1]?.headers?.has("Authorization")).toBeFalsy() + }) + + test("optionally adds Authorization header from constructor", async () => { + const fetcher: NextDrupalBaseOptions["fetcher"] = jest.fn() + const drupal = new NextDrupalBase(BASE_URL, { + auth: authHeader, + fetcher, + }) + + await drupal.fetch(mockUrl, { withAuth: true }) + + expect(fetcher.mock.lastCall[0]).toBe(mockUrl) + expect(fetcher.mock.lastCall[1]?.headers?.get("Authorization")).toBe( + authHeader + ) + }) + + test("optionally adds Authorization header from init", async () => { + const fetcher: NextDrupalBaseOptions["fetcher"] = jest.fn() + const drupal = new NextDrupalBase(BASE_URL, { + fetcher, + }) + + await drupal.fetch(mockUrl, { withAuth: authHeader }) + + expect(fetcher.mock.lastCall[0]).toBe(mockUrl) + expect(fetcher.mock.lastCall[1]?.headers?.get("Authorization")).toBe( + authHeader + ) + }) +}) + +describe("getAccessToken()", () => { + const accessToken = mocks.auth.accessToken + const clientIdSecret = mocks.auth.clientIdSecret + + test("uses the long-lived access token from constructor", async () => { + const longLivedAccessToken: AccessToken = { + ...accessToken, + access_token: `LONG${accessToken.access_token}`, + expires_in: accessToken.expires_in * 1000, + } + const drupal = new NextDrupalBase(BASE_URL, { + accessToken: longLivedAccessToken, + }) + const fetchSpy = spyOnFetch({ + responseBody: accessToken, + }) + + const token = await drupal.getAccessToken(clientIdSecret) + expect(fetchSpy).toHaveBeenCalledTimes(0) + expect(token).toBe(longLivedAccessToken) + }) + + test("throws if auth is not configured", async () => { + const fetchSpy = spyOnFetch({ + responseBody: accessToken, + }) + + const drupal = new NextDrupalBase(BASE_URL) + + await expect(drupal.getAccessToken()).rejects.toThrow( + "auth is not configured. See https://next-drupal.org/docs/client/auth" + ) + expect(fetchSpy).toHaveBeenCalledTimes(0) + }) + + test("throws if auth is not ClientIdSecret", async () => { + const errorMessage = + "'clientId' and 'clientSecret' required. See https://next-drupal.org/docs/client/auth" + const fetchSpy = spyOnFetch({ + responseBody: accessToken, + }) + + const drupal = new NextDrupalBase(BASE_URL, { + auth: mocks.auth.basicAuth, + withAuth: true, + }) + + await expect(drupal.getAccessToken()).rejects.toThrow(errorMessage) + expect(fetchSpy).toHaveBeenCalledTimes(0) + + await expect( + drupal.getAccessToken( + // @ts-ignore + { clientId: clientIdSecret.clientId } + ) + ).rejects.toThrow(errorMessage) + expect(fetchSpy).toHaveBeenCalledTimes(0) + }) + + test("fetches an access token", async () => { + spyOnFetch({ + responseBody: accessToken, + }) + + const logger = mockLogger() + const drupal = new NextDrupalBase(BASE_URL, { + auth: clientIdSecret, + debug: true, + logger, + }) + + const token = await drupal.getAccessToken() + expect(token).toEqual(accessToken) + expect(logger.debug).toHaveBeenCalledWith("Fetching new access token.") + }) + + test("re-uses an access token", async () => { + spyOnFetchOnce({ + responseBody: accessToken, + }) + const fetchSpy = spyOnFetchOnce({ + responseBody: { + ...accessToken, + access_token: "differentAccessToken", + expires_in: 1800, + }, + }) + + const logger = mockLogger() + const drupal = new NextDrupalBase(BASE_URL, { + auth: clientIdSecret, + debug: true, + logger, + }) + + const token1 = await drupal.getAccessToken() + const token2 = await drupal.getAccessToken() + + expect(token1).toEqual(accessToken) + expect(token2).toEqual(token1) + expect(logger.debug).toHaveBeenLastCalledWith( + "Using existing access token." + ) + expect(fetchSpy).toHaveBeenCalledTimes(1) + }) + + test("uses the default auth url", async () => { + const fetchSpy = spyOnFetch({ + responseBody: accessToken, + }) + + const drupal = new NextDrupalBase(BASE_URL, { + auth: clientIdSecret, + }) + + const token = await drupal.getAccessToken() + + expect(token).toEqual(accessToken) + expect(fetchSpy.mock?.lastCall?.[0]).toBe(`${BASE_URL}/oauth/token`) + }) + + test("uses a custom auth url from constructor", async () => { + const fetchSpy = spyOnFetch({ + responseBody: accessToken, + }) + + const drupal = new NextDrupalBase(BASE_URL, { + auth: { ...clientIdSecret, url: "/custom/token" }, + }) + + const token = await drupal.getAccessToken() + + expect(token).toEqual(accessToken) + expect(fetchSpy.mock?.lastCall?.[0]).toBe(`${BASE_URL}/custom/token`) + }) + + test("uses a custom auth url from arguments", async () => { + const fetchSpy = spyOnFetch({ + responseBody: accessToken, + }) + + const drupal = new NextDrupalBase(BASE_URL, { + auth: { ...clientIdSecret, url: "/different/token" }, + }) + + const token = await drupal.getAccessToken({ + ...clientIdSecret, + url: "/custom/token", + }) + expect(token).toEqual(accessToken) + expect(fetchSpy.mock?.lastCall?.[0]).toBe(`${BASE_URL}/custom/token`) + }) + + test("uses the scope from constructor", async () => { + spyOnFetch({ + responseBody: accessToken, + }) + + const logger = mockLogger() + const scope = "admin" + const drupal = new NextDrupalBase(BASE_URL, { + auth: { ...clientIdSecret, scope }, + debug: true, + logger, + }) + + const token = await drupal.getAccessToken() + expect(token).toEqual(accessToken) + expect(logger.debug).toHaveBeenCalledWith("Fetching new access token.") + expect(logger.debug).toHaveBeenCalledWith(`Using scope: ${scope}`) + }) + + test("uses the scope from arguments", async () => { + spyOnFetch({ + responseBody: accessToken, + }) + + const logger = mockLogger() + const scope = "admin" + const drupal = new NextDrupalBase(BASE_URL, { + auth: { + clientId: "not-used", + clientSecret: "not-used", + scope: "not-used", + expires_in: 3600, + }, + debug: true, + logger, + }) + + const token = await drupal.getAccessToken({ ...clientIdSecret, scope }) + + expect(token).toEqual(accessToken) + expect(logger.debug).toHaveBeenCalledWith("Fetching new access token.") + expect(logger.debug).toHaveBeenCalledWith(`Using scope: ${scope}`) + }) + + test("re-uses an access token if scope matches", async () => { + spyOnFetchOnce({ + responseBody: accessToken, + }) + const fetchSpy = spyOnFetchOnce({ + responseBody: { + ...accessToken, + access_token: "differentAccessToken", + expires_in: 1800, + }, + }) + + const logger = mockLogger() + const scope = "admin" + const drupal = new NextDrupalBase(BASE_URL, { + debug: true, + logger, + }) + + const token1 = await drupal.getAccessToken({ ...clientIdSecret, scope }) + const token2 = await drupal.getAccessToken({ ...clientIdSecret, scope }) + + expect(token1).toEqual(accessToken) + expect(token2).toEqual(token1) + expect(logger.debug).toHaveBeenLastCalledWith( + "Using existing access token." + ) + expect(fetchSpy).toHaveBeenCalledTimes(1) + }) + + test("does not re-use an access token if scope does not match", async () => { + spyOnFetchOnce({ + responseBody: accessToken, + }) + const differentToken = { + ...accessToken, + access_token: "differentAccessToken", + expires_in: 1800, + } + const fetchSpy = spyOnFetchOnce({ + responseBody: differentToken, + }) + + const logger = mockLogger() + const scope = "admin" + const drupal = new NextDrupalBase(BASE_URL, { + debug: true, + logger, + }) + + const token1 = await drupal.getAccessToken({ ...clientIdSecret, scope }) + const token2 = await drupal.getAccessToken({ + ...clientIdSecret, + scope: "differs", + }) + + expect(token1).toEqual(accessToken) + expect(token2).toEqual(differentToken) + expect(logger.debug).not.toHaveBeenCalledWith( + "Using existing access token." + ) + expect(fetchSpy).toHaveBeenCalledTimes(2) + }) +}) + +describe("getAuthorizationHeader()", () => { + const accessToken = mocks.auth.accessToken + const basicAuth = mocks.auth.basicAuth + const basicAuthHeader = `Basic ${Buffer.from( + `${basicAuth.username}:${basicAuth.password}` + ).toString("base64")}` + const clientIdSecret = mocks.auth.clientIdSecret + const authCallback = mocks.auth.callback + const authHeader = mocks.auth.customAuthenticationHeader + + test("returns Basic Auth", async () => { + const auth: NextDrupalAuth = basicAuth + const logger = mockLogger() + const drupal = new NextDrupalBase(BASE_URL, { + auth: "is not used", + debug: true, + logger, + }) + + const header = await drupal.getAuthorizationHeader(auth) + + expect(header).toBe(basicAuthHeader) + expect(logger.debug).toHaveBeenLastCalledWith( + "Using basic authorization header." + ) + }) + + test("returns Client Id/Secret", async () => { + const auth: NextDrupalAuth = clientIdSecret + const logger = mockLogger() + const drupal = new NextDrupalBase(BASE_URL, { + auth: "is not used", + debug: true, + logger, + }) + jest + .spyOn(drupal, "getAccessToken") + .mockImplementation(async () => accessToken) + + const header = await drupal.getAuthorizationHeader(auth) + + expect(header).toBe(`Bearer ${accessToken.access_token}`) + expect(logger.debug).toHaveBeenLastCalledWith( + "Using access token authorization header retrieved from Client Id/Secret." + ) + }) + + test("returns Access Token", async () => { + const auth: NextDrupalAuth = accessToken + const logger = mockLogger() + const drupal = new NextDrupalBase(BASE_URL, { + auth: "is not used", + debug: true, + logger, + }) + + const header = await drupal.getAuthorizationHeader(auth) + + expect(header).toBe(`${auth.token_type} ${auth.access_token}`) + expect(logger.debug).toHaveBeenLastCalledWith( + "Using access token authorization header." + ) + }) + + test("returns auth header", async () => { + const logger = mockLogger() + const drupal = new NextDrupalBase(BASE_URL, { + auth: "is not used", + debug: true, + logger, + }) + + const header = await drupal.getAuthorizationHeader(authHeader) + + expect(header).toBe(authHeader) + expect(logger.debug).toHaveBeenLastCalledWith( + "Using custom authorization header." + ) + }) + + test("returns result of auth callback", async () => { + const auth: NextDrupalAuth = jest.fn(authCallback) + const logger = mockLogger() + const drupal = new NextDrupalBase(BASE_URL, { + auth: "is not used", + debug: true, + logger, + }) + + const header = await drupal.getAuthorizationHeader(auth) + + expect(header).toBe(authCallback()) + expect(auth).toBeCalledTimes(1) + expect(logger.debug).toHaveBeenLastCalledWith( + "Using custom authorization callback." + ) + }) + + test("throws an error if auth is undefined", async () => { + const auth = undefined + const drupal = new NextDrupalBase(BASE_URL, { + auth: "is not used", + }) + + await expect(drupal.getAuthorizationHeader(auth)).rejects.toThrow( + "auth is not configured. See https://next-drupal.org/docs/client/auth" + ) + }) + + test("throws an error if auth is unrecognized", async () => { + const auth = { + username: "admin", + token_type: "Bearer", + } + const drupal = new NextDrupalBase(BASE_URL, { + auth: "is not used", + }) + + await expect( + drupal.getAuthorizationHeader( + // @ts-ignore + auth + ) + ).rejects.toThrow( + "auth is not configured. See https://next-drupal.org/docs/client/auth" + ) + }) +}) diff --git a/packages/next-drupal/tests/NextDrupalBase/getters-setters.test.ts b/packages/next-drupal/tests/NextDrupalBase/getters-setters.test.ts new file mode 100644 index 00000000..c05076e6 --- /dev/null +++ b/packages/next-drupal/tests/NextDrupalBase/getters-setters.test.ts @@ -0,0 +1,264 @@ +import { afterEach, describe, expect, jest, test } from "@jest/globals" +import { NextDrupalBase } from "../../src" +import { BASE_URL, mocks } from "../utils" +import type { + AccessToken, + NextDrupalAuthAccessToken, + NextDrupalAuthUsernamePassword, + NextDrupalBaseOptions, +} from "../../src" + +afterEach(() => { + jest.restoreAllMocks() +}) + +describe("apiPrefix", () => { + test("get apiPrefix", () => { + const drupal = new NextDrupalBase(BASE_URL) + expect(drupal.apiPrefix).toBe("") + }) + test("set apiPrefix", () => { + const drupal = new NextDrupalBase(BASE_URL) + drupal.apiPrefix = "/api" + expect(drupal.apiPrefix).toBe("/api") + }) + test('set apiPrefix and prefixes with "/"', () => { + const drupal = new NextDrupalBase(BASE_URL) + drupal.apiPrefix = "api" + expect(drupal.apiPrefix).toBe("/api") + }) +}) + +describe("auth", () => { + describe("throws an error if invalid Basic Auth", () => { + test("missing username", () => { + expect(() => { + const drupal = new NextDrupalBase(BASE_URL) + // @ts-ignore + drupal.auth = { + password: "password", + } + }).toThrow( + "'username' and 'password' are required for auth. See https://next-drupal.org/docs/client/auth" + ) + }) + + test("missing password", () => { + expect(() => { + const drupal = new NextDrupalBase(BASE_URL) + // @ts-ignore + drupal.auth = { + username: "admin", + } + }).toThrow( + "'username' and 'password' are required for auth. See https://next-drupal.org/docs/client/auth" + ) + }) + }) + + describe("throws an error if invalid Access Token", () => { + test("missing access_token", () => { + expect(() => { + const drupal = new NextDrupalBase(BASE_URL) + // @ts-ignore + drupal.auth = { + token_type: mocks.auth.accessToken.token_type, + } + }).toThrow( + "'access_token' and 'token_type' are required for auth. See https://next-drupal.org/docs/client/auth" + ) + }) + + test("missing token_type", () => { + expect(() => { + const drupal = new NextDrupalBase(BASE_URL) + // @ts-ignore + drupal.auth = { + access_token: mocks.auth.accessToken.access_token, + } + }).toThrow( + "'access_token' and 'token_type' are required for auth. See https://next-drupal.org/docs/client/auth" + ) + }) + }) + + describe("throws an error if invalid Client ID/Secret", () => { + test("missing clientId", () => { + expect(() => { + const drupal = new NextDrupalBase(BASE_URL) + // @ts-ignore + drupal.auth = { + clientSecret: mocks.auth.clientIdSecret.clientSecret, + } + }).toThrow( + "'clientId' and 'clientSecret' are required for auth. See https://next-drupal.org/docs/client/auth" + ) + }) + + test("missing clientSecret", () => { + expect(() => { + const drupal = new NextDrupalBase(BASE_URL) + // @ts-ignore + drupal.auth = { + clientId: mocks.auth.clientIdSecret.clientId, + } + }).toThrow( + "'clientId' and 'clientSecret' are required for auth. See https://next-drupal.org/docs/client/auth" + ) + }) + }) + + test("get auth", () => { + const drupal = new NextDrupalBase(BASE_URL, { + auth: mocks.auth.customAuthenticationHeader, + }) + expect(drupal.auth).toBe(mocks.auth.customAuthenticationHeader) + }) + + test("sets Basic Auth", () => { + const basicAuth: NextDrupalAuthUsernamePassword = { + ...mocks.auth.basicAuth, + } + const drupal = new NextDrupalBase(BASE_URL) + drupal.auth = basicAuth + expect(drupal.auth).toMatchObject({ ...basicAuth }) + }) + + test("sets Access Token", () => { + const accessToken = { + ...mocks.auth.accessToken, + } + const drupal = new NextDrupalBase(BASE_URL) + drupal.auth = accessToken + expect(drupal.auth).toMatchObject({ ...accessToken }) + }) + + test("sets Client ID/Secret", () => { + const clientIdSecret = { + ...mocks.auth.clientIdSecret, + } + const drupal = new NextDrupalBase(BASE_URL) + drupal.auth = clientIdSecret + expect(drupal.auth).toMatchObject({ ...clientIdSecret }) + }) + + test("sets auth function", () => { + const authFunction = mocks.auth.callback + const drupal = new NextDrupalBase(BASE_URL) + drupal.auth = authFunction + expect(drupal.auth).toBe(authFunction) + }) + + test("sets custom Authorization string", () => { + const authString = `${mocks.auth.customAuthenticationHeader}` + const drupal = new NextDrupalBase(BASE_URL) + drupal.auth = authString + expect(drupal.auth).toBe(authString) + }) + + test("sets a default access token url", () => { + const clientIdSecret = { + ...mocks.auth.clientIdSecret, + } + const drupal = new NextDrupalBase(BASE_URL) + drupal.auth = clientIdSecret + expect(drupal.auth.url).toBe("/oauth/token") + }) + + test("can override the default access token url", () => { + const clientIdSecret = { + ...mocks.auth.clientIdSecret, + url: "/custom/oauth/token", + } + const drupal = new NextDrupalBase(BASE_URL) + drupal.auth = clientIdSecret + expect(drupal.auth.url).toBe("/custom/oauth/token") + }) +}) + +describe("headers", () => { + const headers = { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + } as NextDrupalBaseOptions["headers"] + const expectedHeaders = {} + Object.keys(headers).forEach((header) => { + expectedHeaders[header.toLowerCase()] = headers[header] + }) + + test("get headers", () => { + const drupal = new NextDrupalBase(BASE_URL, { headers }) + + expect(Object.fromEntries(drupal.headers.entries())).toMatchObject( + expectedHeaders + ) + }) + + test("set headers using key-value pairs", () => { + const keyValuePairs = [ + ["Content-Type", headers["Content-Type"]], + ["Accept", headers.Accept], + ] + + const drupal = new NextDrupalBase(BASE_URL) + drupal.headers = keyValuePairs + + expect(Object.fromEntries(drupal.headers.entries())).toMatchObject( + expectedHeaders + ) + }) + + test("set headers using object literal", () => { + const drupal = new NextDrupalBase(BASE_URL) + + drupal.headers = headers + + expect(Object.fromEntries(drupal.headers.entries())).toMatchObject( + expectedHeaders + ) + }) + + test("set headers using Headers object", () => { + const headersObject = new Headers() + headersObject.set("Content-Type", headers["Content-Type"]) + headersObject.set("Accept", headers["Accept"]) + + const drupal = new NextDrupalBase(BASE_URL) + drupal.headers = headersObject + + expect(Object.fromEntries(drupal.headers.entries())).toMatchObject( + expectedHeaders + ) + }) +}) + +describe("token", () => { + test("get token", () => { + const accessToken = { + ...mocks.auth.accessToken, + } as NextDrupalAuthAccessToken + + const drupal = new NextDrupalBase(BASE_URL) + drupal.token = accessToken + expect(drupal.token).toBe(accessToken) + }) + + test("set token", () => { + function getExpiresOn(token: AccessToken): number { + return Date.now() + token.expires_in * 1000 + } + + const accessToken = { + ...mocks.auth.accessToken, + } as NextDrupalAuthAccessToken + const drupal = new NextDrupalBase(BASE_URL) + + const before = getExpiresOn(accessToken) + drupal.token = accessToken + const after = getExpiresOn(accessToken) + + expect(drupal.token).toBe(accessToken) + expect(drupal._tokenExpiresOn).toBeGreaterThanOrEqual(before) + expect(drupal._tokenExpiresOn).toBeLessThanOrEqual(after) + }) +}) diff --git a/packages/next-drupal/tests/DrupalClient/__snapshots__/pages-router-methods.test.ts.snap b/packages/next-drupal/tests/NextDrupalPages/__snapshots__/pages-router-methods.test.ts.snap similarity index 100% rename from packages/next-drupal/tests/DrupalClient/__snapshots__/pages-router-methods.test.ts.snap rename to packages/next-drupal/tests/NextDrupalPages/__snapshots__/pages-router-methods.test.ts.snap diff --git a/packages/next-drupal/tests/NextDrupalPages/constructor.test.ts b/packages/next-drupal/tests/NextDrupalPages/constructor.test.ts new file mode 100644 index 00000000..3779720c --- /dev/null +++ b/packages/next-drupal/tests/NextDrupalPages/constructor.test.ts @@ -0,0 +1,43 @@ +import { afterEach, describe, expect, jest, test } from "@jest/globals" +import { Jsona } from "jsona" +import { NextDrupal, NextDrupalBase, NextDrupalPages } from "../../src" +import { BASE_URL } from "../utils" + +afterEach(() => { + jest.restoreAllMocks() +}) + +describe("baseUrl parameter", () => { + test("returns a NextDrupalPages", () => { + expect(new NextDrupalPages(BASE_URL)).toBeInstanceOf(NextDrupalPages) + expect(new NextDrupalPages(BASE_URL)).toBeInstanceOf(NextDrupal) + expect(new NextDrupalPages(BASE_URL)).toBeInstanceOf(NextDrupalBase) + }) +}) + +describe("options parameter", () => { + describe("serializer", () => { + test("defaults to `new Jsona()`", () => { + const drupal = new NextDrupalPages(BASE_URL) + expect(drupal.serializer).toBeInstanceOf(Jsona) + }) + + test("sets up a custom serializer", () => { + const customSerializer: NextDrupalPages["serializer"] = { + deserialize( + body: Record, + options?: Record + ): unknown { + return { + deserialized: true, + } + }, + } + + const drupal = new NextDrupalPages(BASE_URL, { + serializer: customSerializer, + }) + expect(drupal.serializer).toBe(customSerializer) + }) + }) +}) diff --git a/packages/next-drupal/tests/DrupalClient/pages-router-methods.test.ts b/packages/next-drupal/tests/NextDrupalPages/pages-router-methods.test.ts similarity index 65% rename from packages/next-drupal/tests/DrupalClient/pages-router-methods.test.ts rename to packages/next-drupal/tests/NextDrupalPages/pages-router-methods.test.ts index e99ff79b..011a407f 100644 --- a/packages/next-drupal/tests/DrupalClient/pages-router-methods.test.ts +++ b/packages/next-drupal/tests/NextDrupalPages/pages-router-methods.test.ts @@ -1,8 +1,12 @@ import { afterEach, describe, expect, jest, test } from "@jest/globals" import { GetStaticPropsContext, NextApiRequest, NextApiResponse } from "next" -import { DRAFT_DATA_COOKIE_NAME, DrupalClient } from "../../src" +import { DRAFT_DATA_COOKIE_NAME, NextDrupalPages } from "../../src" import { BASE_URL, mockLogger, mocks, spyOnFetch } from "../utils" -import type { DrupalNode, JsonApiResourceWithPath } from "../../src" +import type { + DrupalNode, + JsonApiResourceWithPath, + NextDrupalAuth, +} from "../../src" jest.setTimeout(10000) @@ -29,9 +33,9 @@ describe("buildStaticPathsFromResources()", () => { ] test("builds static paths from resources", () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupalPages(BASE_URL) - expect(client.buildStaticPathsFromResources(resources)).toMatchObject([ + expect(drupal.buildStaticPathsFromResources(resources)).toMatchObject([ { params: { slug: ["blog", "post", "one"], @@ -45,7 +49,7 @@ describe("buildStaticPathsFromResources()", () => { ]) expect( - client.buildStaticPathsFromResources(resources, { locale: "es" }) + drupal.buildStaticPathsFromResources(resources, { locale: "es" }) ).toMatchObject([ { locale: "es", @@ -63,22 +67,22 @@ describe("buildStaticPathsFromResources()", () => { }) test("builds static paths from resources with pathPrefix", () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupalPages(BASE_URL) - const paths = client.buildStaticPathsFromResources(resources, { + const paths = drupal.buildStaticPathsFromResources(resources, { pathPrefix: "blog", }) - const paths2 = client.buildStaticPathsFromResources(resources, { + const paths2 = drupal.buildStaticPathsFromResources(resources, { pathPrefix: "/blog", }) - const paths3 = client.buildStaticPathsFromResources(resources, { + const paths3 = drupal.buildStaticPathsFromResources(resources, { pathPrefix: "/blog/post", locale: "es", }) - const paths4 = client.buildStaticPathsFromResources(resources, { + const paths4 = drupal.buildStaticPathsFromResources(resources, { pathPrefix: "blog/post", locale: "es", }) @@ -115,7 +119,7 @@ describe("buildStaticPathsFromResources()", () => { }) test('converts frontPage path to "/"', () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupalPages(BASE_URL) const resources: Pick[] = [ { @@ -127,7 +131,7 @@ describe("buildStaticPathsFromResources()", () => { }, ] - expect(client.buildStaticPathsFromResources(resources)).toMatchObject([ + expect(drupal.buildStaticPathsFromResources(resources)).toMatchObject([ { params: { slug: [""], @@ -139,11 +143,11 @@ describe("buildStaticPathsFromResources()", () => { describe("buildStaticPathsParamsFromPaths()", () => { test("builds static paths from paths", () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupalPages(BASE_URL) const paths = ["/blog/post/one", "/blog/post/two", "/blog/post/three"] - expect(client.buildStaticPathsParamsFromPaths(paths)).toMatchObject([ + expect(drupal.buildStaticPathsParamsFromPaths(paths)).toMatchObject([ { params: { slug: ["blog", "post", "one"], @@ -162,7 +166,7 @@ describe("buildStaticPathsParamsFromPaths()", () => { ]) expect( - client.buildStaticPathsParamsFromPaths(paths, { locale: "en" }) + drupal.buildStaticPathsParamsFromPaths(paths, { locale: "en" }) ).toMatchObject([ { locale: "en", @@ -186,24 +190,24 @@ describe("buildStaticPathsParamsFromPaths()", () => { }) test("builds static paths from paths with pathPrefix", () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupalPages(BASE_URL) - const paths = client.buildStaticPathsParamsFromPaths( + const paths = drupal.buildStaticPathsParamsFromPaths( ["/blog/post/one", "/blog/post/two", "/blog/post"], { pathPrefix: "blog" } ) - const paths2 = client.buildStaticPathsParamsFromPaths( + const paths2 = drupal.buildStaticPathsParamsFromPaths( ["/blog/post/one", "/blog/post/two", "/blog/post"], { pathPrefix: "/blog" } ) - const paths3 = client.buildStaticPathsParamsFromPaths( + const paths3 = drupal.buildStaticPathsParamsFromPaths( ["blog/post/one", "blog/post/two", "blog/post"], { pathPrefix: "/blog" } ) - const paths4 = client.buildStaticPathsParamsFromPaths( + const paths4 = drupal.buildStaticPathsParamsFromPaths( ["blog/post/one", "blog/post/two", "blog/post"], { pathPrefix: "blog" } ) @@ -234,238 +238,188 @@ describe("buildStaticPathsParamsFromPaths()", () => { describe("getAuthFromContextAndOptions()", () => { const clientIdSecret = mocks.auth.clientIdSecret - const accessToken = mocks.auth.accessToken + const context = { + preview: false, + params: { slug: ["recipes", "deep-mediterranean-quiche"] }, + } test("should use the withAuth option if provided and NOT in preview", async () => { - const client = new DrupalClient(BASE_URL, { + const drupal = new NextDrupalPages(BASE_URL, { auth: clientIdSecret, + throwJsonApiErrors: false, + logger: mockLogger(), }) - const fetchSpy = spyOnFetch() - jest - .spyOn(client, "getAccessToken") - .mockImplementation(async () => accessToken) - await client.getResourceFromContext( - "node--article", - { - preview: false, - }, - { - withAuth: true, - } - ) + let auth: boolean | NextDrupalAuth = true - expect(fetchSpy).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: `${accessToken.token_type} ${accessToken.access_token}`, - }), + expect( + drupal.getAuthFromContextAndOptions(context, { + withAuth: auth, }) - ) + ).toBe(auth) - await client.getResourceFromContext( - "node--article", - { - preview: false, - }, - { - withAuth: { - clientId: "foo", - clientSecret: "bar", - scope: "baz", - }, - } - ) + auth = { + clientId: "foo", + clientSecret: "bar", + scope: "baz", + } - expect(fetchSpy).toHaveBeenLastCalledWith( - expect.anything(), - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: `${accessToken.token_type} ${accessToken.access_token}`, - }), + expect( + drupal.getAuthFromContextAndOptions(context, { + withAuth: auth, }) - ) + ).toBe(auth) }) test("should fallback to the global auth if NOT in preview and no withAuth option provided", async () => { - const client = new DrupalClient(BASE_URL, { + const drupal = new NextDrupalPages(BASE_URL, { auth: clientIdSecret, - }) - const fetchSpy = spyOnFetch() - - await client.getResourceFromContext("node--article", { - preview: false, + throwJsonApiErrors: false, + logger: mockLogger(), }) - expect(fetchSpy).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - headers: expect.not.objectContaining({ - Authorization: expect.anything(), - }), - }) - ) + expect(drupal.getAuthFromContextAndOptions(context, {})).toBe(false) - const client2 = new DrupalClient(BASE_URL, { + const drupal2 = new NextDrupalPages(BASE_URL, { auth: clientIdSecret, withAuth: true, + throwJsonApiErrors: false, + logger: mockLogger(), }) - jest - .spyOn(client2, "getAccessToken") - .mockImplementation(async () => accessToken) - await client2.getResourceFromContext("node--article", { - preview: false, - }) - - expect(fetchSpy).toHaveBeenLastCalledWith( - expect.anything(), - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: `${accessToken.token_type} ${accessToken.access_token}`, - }), - }) - ) + expect(drupal2.getAuthFromContextAndOptions(context, {})).toBe(true) }) - test("should NOT use the global auth if in preview", async () => { - const client = new DrupalClient(BASE_URL, { + test("should NOT use the global auth if in preview and no plugin in previewData", async () => { + const drupal = new NextDrupalPages(BASE_URL, { auth: clientIdSecret, withAuth: true, - }) - const fetchSpy = jest.spyOn(client, "fetch") - spyOnFetch() - - await client.getResourceFromContext("node--article", { - preview: true, + throwJsonApiErrors: false, + logger: mockLogger(), }) - expect(fetchSpy).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - withAuth: null, - }) - ) + expect( + drupal.getAuthFromContextAndOptions( + { + ...context, + preview: true, + }, + {} + ) + ).toBe(null) }) test("should use the scope from context if in preview and using the simple_oauth plugin", async () => { - const client = new DrupalClient(BASE_URL, { + const drupal = new NextDrupalPages(BASE_URL, { auth: clientIdSecret, + throwJsonApiErrors: false, + logger: mockLogger(), }) - const fetchSpy = jest.spyOn(client, "fetch") - spyOnFetch() - await client.getResourceFromContext("node--article", { - preview: true, - previewData: { - plugin: "simple_oauth", - scope: "editor", - }, - }) - - expect(fetchSpy).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - withAuth: { - ...clientIdSecret, - scope: "editor", - url: "/oauth/token", + expect( + drupal.getAuthFromContextAndOptions( + { + ...context, + preview: true, + previewData: { + plugin: "simple_oauth", + scope: "editor", + }, }, - }) - ) + {} + ) + ).toMatchObject({ + ...clientIdSecret, + scope: "editor", + url: "/oauth/token", + }) }) test("should use the scope from context even with global withAuth if in preview and using the simple_oauth plugin", async () => { - const client = new DrupalClient(BASE_URL, { + const drupal = new NextDrupalPages(BASE_URL, { auth: { ...clientIdSecret, scope: "administrator", }, withAuth: true, - }) - const fetchSpy = jest.spyOn(client, "fetch") - spyOnFetch() - - await client.getResourceFromContext("node--article", { - preview: true, - previewData: { - plugin: "simple_oauth", - scope: "editor", - }, + throwJsonApiErrors: false, + logger: mockLogger(), }) - expect(fetchSpy).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - withAuth: { - ...clientIdSecret, - scope: "editor", - url: "/oauth/token", + expect( + drupal.getAuthFromContextAndOptions( + { + ...context, + preview: true, + previewData: { + plugin: "simple_oauth", + scope: "editor", + }, }, - }) - ) + {} + ) + ).toMatchObject({ + ...clientIdSecret, + scope: "editor", + url: "/oauth/token", + }) }) test("should use the access_token from context if in preview and using the jwt plugin", async () => { - const client = new DrupalClient(BASE_URL, { + const drupal = new NextDrupalPages(BASE_URL, { auth: clientIdSecret, + throwJsonApiErrors: false, + logger: mockLogger(), }) - const fetchSpy = spyOnFetch() - await client.getResourceFromContext("node--article", { - preview: true, - previewData: { - plugin: "jwt", - access_token: "example-token", - }, - }) - - expect(fetchSpy).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: `Bearer example-token`, - }), - }) - ) + expect( + drupal.getAuthFromContextAndOptions( + { + ...context, + preview: true, + previewData: { + plugin: "jwt", + access_token: "example-token", + }, + }, + {} + ) + ).toBe(`Bearer example-token`) }) test("should use the access token from context even with global withAuth if in preview and using the jwt plugin", async () => { - const client = new DrupalClient(BASE_URL, { + const drupal = new NextDrupalPages(BASE_URL, { auth: { ...clientIdSecret, scope: "administrator", }, withAuth: true, - }) - const fetchSpy = spyOnFetch() - - await client.getResourceFromContext("node--article", { - preview: true, - previewData: { - plugin: "jwt", - access_token: "example-token", - }, + throwJsonApiErrors: false, + logger: mockLogger(), }) - expect(fetchSpy).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: `Bearer example-token`, - }), - }) - ) + expect( + drupal.getAuthFromContextAndOptions( + { + ...context, + preview: true, + previewData: { + plugin: "jwt", + access_token: "example-token", + }, + }, + {} + ) + ).toBe(`Bearer example-token`) }) }) describe("getPathFromContext()", () => { test("returns a path from context", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupalPages(BASE_URL) expect( - client.getPathFromContext({ + drupal.getPathFromContext({ params: { slug: ["foo"], }, @@ -473,7 +427,7 @@ describe("getPathFromContext()", () => { ).toEqual("/foo") expect( - client.getPathFromContext({ + drupal.getPathFromContext({ params: { slug: ["foo", "bar"], }, @@ -481,7 +435,7 @@ describe("getPathFromContext()", () => { ).toEqual("/foo/bar") expect( - client.getPathFromContext({ + drupal.getPathFromContext({ locale: "en", defaultLocale: "es", params: { @@ -491,17 +445,17 @@ describe("getPathFromContext()", () => { ).toEqual("/en/foo/bar") expect( - client.getPathFromContext({ + drupal.getPathFromContext({ params: { slug: [], }, }) ).toEqual("/home") - client.frontPage = "/front" + drupal.frontPage = "/front" expect( - client.getPathFromContext({ + drupal.getPathFromContext({ params: { slug: [], }, @@ -509,7 +463,7 @@ describe("getPathFromContext()", () => { ).toEqual("/front") expect( - client.getPathFromContext({ + drupal.getPathFromContext({ locale: "es", defaultLocale: "en", params: { @@ -520,10 +474,10 @@ describe("getPathFromContext()", () => { }) test("returns a path from context with pathPrefix", () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupalPages(BASE_URL) expect( - client.getPathFromContext( + drupal.getPathFromContext( { params: { slug: ["bar", "baz"], @@ -536,7 +490,7 @@ describe("getPathFromContext()", () => { ).toEqual("/foo/bar/baz") expect( - client.getPathFromContext( + drupal.getPathFromContext( { params: { slug: ["bar", "baz"], @@ -549,7 +503,7 @@ describe("getPathFromContext()", () => { ).toEqual("/foo/bar/baz") expect( - client.getPathFromContext( + drupal.getPathFromContext( { locale: "en", defaultLocale: "en", @@ -564,7 +518,7 @@ describe("getPathFromContext()", () => { ).toEqual("/foo/bar/baz") expect( - client.getPathFromContext( + drupal.getPathFromContext( { locale: "es", defaultLocale: "en", @@ -579,7 +533,7 @@ describe("getPathFromContext()", () => { ).toEqual("/es/foo/bar/baz") expect( - client.getPathFromContext( + drupal.getPathFromContext( { locale: "es", defaultLocale: "en", @@ -591,12 +545,12 @@ describe("getPathFromContext()", () => { pathPrefix: "/foo", } ) - ).toEqual("/es/foo/home") + ).toEqual("/es/foo") - client.frontPage = "/baz" + drupal.frontPage = "/baz" expect( - client.getPathFromContext( + drupal.getPathFromContext( { locale: "en", defaultLocale: "en", @@ -608,26 +562,26 @@ describe("getPathFromContext()", () => { pathPrefix: "foo", } ) - ).toEqual("/foo/baz") + ).toEqual("/foo") expect( - client.getPathFromContext( + drupal.getPathFromContext( { params: { slug: [], }, }, { - pathPrefix: "/foo/bar", + pathPrefix: "", } ) - ).toEqual("/foo/bar/baz") + ).toEqual("/baz") }) test("encodes path with punctuation", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupalPages(BASE_URL) - const path = client.getPathFromContext({ + const path = drupal.getPathFromContext({ params: { slug: ["path&with^punc&in$path"], }, @@ -635,7 +589,7 @@ describe("getPathFromContext()", () => { expect(path).toEqual("/path%26with%5Epunc%26in%24path") - const translatedPath = await client.translatePath(path) + const translatedPath = await drupal.translatePath(path) expect(translatedPath).toMatchSnapshot() }) @@ -643,21 +597,21 @@ describe("getPathFromContext()", () => { describe("getPathsFromContext()", () => { test("is an alias for getStaticPathsFromContext", () => { - const client = new DrupalClient(BASE_URL) - expect(client.getPathsFromContext).toBe(client.getStaticPathsFromContext) + const drupal = new NextDrupalPages(BASE_URL) + expect(drupal.getPathsFromContext).toBe(drupal.getStaticPathsFromContext) }) }) describe("getResourceCollectionFromContext()", () => { test("fetches a resource collection", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupalPages(BASE_URL) const context: GetStaticPropsContext = { locale: "en", defaultLocale: "en", } - const articles = await client.getResourceCollectionFromContext( + const articles = await drupal.getResourceCollectionFromContext( "node--article", context, { @@ -671,14 +625,14 @@ describe("getResourceCollectionFromContext()", () => { }) test("fetches a resource collection using locale", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupalPages(BASE_URL) const context: GetStaticPropsContext = { locale: "es", defaultLocale: "en", } - const articles = await client.getResourceCollectionFromContext( + const articles = await drupal.getResourceCollectionFromContext( "node--article", context, { @@ -694,14 +648,14 @@ describe("getResourceCollectionFromContext()", () => { }) test("fetches raw data", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupalPages(BASE_URL) const context: GetStaticPropsContext = { locale: "en", defaultLocale: "en", } - const recipes = await client.getResourceCollectionFromContext( + const recipes = await drupal.getResourceCollectionFromContext( "node--recipe", context, { @@ -717,7 +671,7 @@ describe("getResourceCollectionFromContext()", () => { }) test("throws an error for invalid resource type", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupalPages(BASE_URL) const context: GetStaticPropsContext = { locale: "en", @@ -725,7 +679,7 @@ describe("getResourceCollectionFromContext()", () => { } await expect( - client.getResourceCollectionFromContext( + drupal.getResourceCollectionFromContext( "RESOURCE-DOES-NOT-EXIST", context ) @@ -733,7 +687,7 @@ describe("getResourceCollectionFromContext()", () => { }) test("throws an error for invalid params", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupalPages(BASE_URL) const context: GetStaticPropsContext = { locale: "en", @@ -741,7 +695,7 @@ describe("getResourceCollectionFromContext()", () => { } await expect( - client.getResourceCollectionFromContext( + drupal.getResourceCollectionFromContext( "node--recipe", context, { @@ -756,56 +710,50 @@ describe("getResourceCollectionFromContext()", () => { }) test("makes un-authenticated requests by default", async () => { - const client = new DrupalClient(BASE_URL) - const fetchSpy = jest.spyOn(client, "fetch") + const drupal = new NextDrupalPages(BASE_URL) + const drupalFetchSpy = jest.spyOn(drupal, "fetch") const context: GetStaticPropsContext = { locale: "en", defaultLocale: "en", } - await client.getResourceCollectionFromContext("node--recipe", context) - expect(fetchSpy).toHaveBeenCalledWith(expect.anything(), { + await drupal.getResourceCollectionFromContext("node--recipe", context) + expect(drupalFetchSpy).toHaveBeenCalledWith(expect.anything(), { withAuth: false, }) }) test("makes authenticated requests with withAuth option", async () => { - const client = new DrupalClient(BASE_URL, { + const drupal = new NextDrupalPages(BASE_URL, { useDefaultResourceTypeEntry: true, auth: `Bearer sample-token`, }) const fetchSpy = spyOnFetch() - jest.spyOn(client, "getAccessToken") const context: GetStaticPropsContext = { locale: "en", defaultLocale: "en", } - await client.getResourceCollectionFromContext("node--recipe", context, { + await drupal.getResourceCollectionFromContext("node--recipe", context, { withAuth: true, }) - expect(fetchSpy).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: "Bearer sample-token", - }), - }) - ) + expect( + (fetchSpy.mock.lastCall[1].headers as Headers).get("Authorization") + ).toBe("Bearer sample-token") }) }) describe("getResourceFromContext()", () => { test("fetches a resource from context", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupalPages(BASE_URL) const context: GetStaticPropsContext = { params: { slug: ["recipes", "deep-mediterranean-quiche"], }, } - const recipe = await client.getResourceFromContext( + const recipe = await drupal.getResourceFromContext( "node--recipe", context ) @@ -814,13 +762,13 @@ describe("getResourceFromContext()", () => { }) test("fetches a resource from context with params", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupalPages(BASE_URL) const context: GetStaticPropsContext = { params: { slug: ["recipes", "deep-mediterranean-quiche"], }, } - const recipe = await client.getResourceFromContext( + const recipe = await drupal.getResourceFromContext( "node--recipe", context, { @@ -834,7 +782,7 @@ describe("getResourceFromContext()", () => { }) test("fetches a resource from context using locale", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupalPages(BASE_URL) const context: GetStaticPropsContext = { params: { slug: ["recipes", "quiche-mediterráneo-profundo"], @@ -842,7 +790,7 @@ describe("getResourceFromContext()", () => { locale: "es", defaultLocale: "en", } - const recipe = await client.getResourceFromContext( + const recipe = await drupal.getResourceFromContext( "node--recipe", context, { @@ -856,14 +804,14 @@ describe("getResourceFromContext()", () => { }) test("fetches raw data", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupalPages(BASE_URL) const context: GetStaticPropsContext = { params: { slug: ["recipes", "deep-mediterranean-quiche"], }, } - const recipe = await client.getResourceFromContext( + const recipe = await drupal.getResourceFromContext( "node--recipe", context, { @@ -878,7 +826,7 @@ describe("getResourceFromContext()", () => { }) test("fetches a resource from context by revision", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupalPages(BASE_URL) const context: GetStaticPropsContext = { params: { slug: ["recipes", "quiche-mediterráneo-profundo"], @@ -886,7 +834,7 @@ describe("getResourceFromContext()", () => { locale: "es", defaultLocale: "en", } - const recipe = await client.getResourceFromContext( + const recipe = await drupal.getResourceFromContext( "node--recipe", context, { @@ -898,7 +846,7 @@ describe("getResourceFromContext()", () => { context.previewData = { resourceVersion: "rel:latest-version" } - const latestRevision = await client.getResourceFromContext( + const latestRevision = await drupal.getResourceFromContext( "node--recipe", context, { @@ -914,7 +862,7 @@ describe("getResourceFromContext()", () => { }) test("throws an error for invalid revision", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupalPages(BASE_URL) const context: GetStaticPropsContext = { previewData: { resourceVersion: "id:-11", @@ -925,7 +873,7 @@ describe("getResourceFromContext()", () => { } await expect( - client.getResourceFromContext("node--recipe", context, { + drupal.getResourceFromContext("node--recipe", context, { params: { "fields[node--recipe]": "drupal_internal__vid", }, @@ -936,7 +884,7 @@ describe("getResourceFromContext()", () => { }) test("throws an error if revision access is forbidden", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupalPages(BASE_URL) const context: GetStaticPropsContext = { previewData: { @@ -948,7 +896,7 @@ describe("getResourceFromContext()", () => { } await expect( - client.getResourceFromContext("node--recipe", context, { + drupal.getResourceFromContext("node--recipe", context, { params: { "fields[node--recipe]": "title", }, @@ -959,16 +907,16 @@ describe("getResourceFromContext()", () => { }) test("makes un-authenticated requests by default", async () => { - const client = new DrupalClient(BASE_URL) - const fetchSpy = jest.spyOn(client, "fetch") + const drupal = new NextDrupalPages(BASE_URL) + const drupalFetchSpy = jest.spyOn(drupal, "fetch") const context: GetStaticPropsContext = { params: { slug: ["recipes", "deep-mediterranean-quiche"], }, } - await client.getResourceFromContext("node--recipe", context) - expect(fetchSpy).toHaveBeenCalledWith( + await drupal.getResourceFromContext("node--recipe", context) + expect(drupalFetchSpy).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ withAuth: false, @@ -977,12 +925,16 @@ describe("getResourceFromContext()", () => { }) test("makes authenticated requests with withAuth option", async () => { - const client = new DrupalClient(BASE_URL, { + const drupal = new NextDrupalPages(BASE_URL, { useDefaultResourceTypeEntry: true, auth: `Bearer sample-token`, + throwJsonApiErrors: false, + logger: mockLogger(), + }) + const fetchSpy = spyOnFetch({ + responseBody: { "resolvedResource#uri{0}": { body: "{}" } }, + status: 207, }) - const fetchSpy = spyOnFetch() - jest.spyOn(client, "getAccessToken") const context: GetStaticPropsContext = { params: { @@ -990,27 +942,26 @@ describe("getResourceFromContext()", () => { }, } - await client.getResourceFromContext("node--recipe", context, { + await drupal.getResourceFromContext("node--recipe", context, { withAuth: true, }) - expect(fetchSpy).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: "Bearer sample-token", - }), - }) - ) + expect( + (fetchSpy.mock.lastCall[1].headers as Headers).get("Authorization") + ).toBe("Bearer sample-token") }) test("makes authenticated requests when preview is true", async () => { - const client = new DrupalClient(BASE_URL, { + const drupal = new NextDrupalPages(BASE_URL, { useDefaultResourceTypeEntry: true, auth: `Bearer sample-token`, + throwJsonApiErrors: false, + logger: mockLogger(), + }) + const fetchSpy = spyOnFetch({ + responseBody: { "resolvedResource#uri{0}": { body: "{}" } }, + status: 207, }) - const fetchSpy = spyOnFetch() - jest.spyOn(client, "getAccessToken") const context: GetStaticPropsContext = { preview: true, @@ -1023,22 +974,17 @@ describe("getResourceFromContext()", () => { }, } - await client.getResourceFromContext("node--recipe", context) + await drupal.getResourceFromContext("node--recipe", context) - expect(fetchSpy).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: `Bearer sample-token`, - }), - }) - ) + expect( + (fetchSpy.mock.lastCall[1].headers as Headers).get("Authorization") + ).toBe("Bearer sample-token") }) test("accepts a translated path", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupalPages(BASE_URL) - const path = await client.translatePath("recipes/deep-mediterranean-quiche") + const path = await drupal.translatePath("recipes/deep-mediterranean-quiche") const context: GetStaticPropsContext = { params: { @@ -1046,7 +992,7 @@ describe("getResourceFromContext()", () => { }, } - const recipe = await client.getResourceFromContext(path, context, { + const recipe = await drupal.getResourceFromContext(path, context, { params: { "fields[node--recipe]": "title,path,status", }, @@ -1058,9 +1004,9 @@ describe("getResourceFromContext()", () => { describe("getSearchIndexFromContext()", () => { test("calls getSearchIndex() with context data", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupalPages(BASE_URL) const fetchSpy = jest - .spyOn(client, "getSearchIndex") + .spyOn(drupal, "getSearchIndex") .mockImplementation(async () => jest.fn()) const name = "resource-name" const locale = "en-uk" @@ -1069,7 +1015,7 @@ describe("getSearchIndexFromContext()", () => { deserialize: true, } - await client.getSearchIndexFromContext( + await drupal.getSearchIndexFromContext( name, { locale, defaultLocale }, options @@ -1085,17 +1031,17 @@ describe("getSearchIndexFromContext()", () => { describe("getStaticPathsFromContext()", () => { test("returns static paths from context", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupalPages(BASE_URL) - const paths = await client.getStaticPathsFromContext("node--article", {}) + const paths = await drupal.getStaticPathsFromContext("node--article", {}) expect(paths).toMatchSnapshot() }) test("returns static paths from context with locale", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupalPages(BASE_URL) - const paths = await client.getStaticPathsFromContext("node--article", { + const paths = await drupal.getStaticPathsFromContext("node--article", { locales: ["en", "es"], defaultLocale: "en", }) @@ -1104,9 +1050,9 @@ describe("getStaticPathsFromContext()", () => { }) test("returns static paths for multiple resource types from context", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupalPages(BASE_URL) - const paths = await client.getStaticPathsFromContext( + const paths = await drupal.getStaticPathsFromContext( ["node--article", "node--recipe"], { locales: ["en", "es"], @@ -1118,9 +1064,9 @@ describe("getStaticPathsFromContext()", () => { }) test("returns static paths from context with params", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupalPages(BASE_URL) - const paths = await client.getStaticPathsFromContext( + const paths = await drupal.getStaticPathsFromContext( "node--article", {}, { @@ -1134,27 +1080,26 @@ describe("getStaticPathsFromContext()", () => { }) test("makes un-authenticated requests by default", async () => { - const client = new DrupalClient(BASE_URL) - const fetchSpy = jest.spyOn(client, "fetch") + const drupal = new NextDrupalPages(BASE_URL) + const drupalFetchSpy = jest.spyOn(drupal, "fetch") - await client.getStaticPathsFromContext("node--article", { + await drupal.getStaticPathsFromContext("node--article", { locales: ["en", "es"], defaultLocale: "en", }) - expect(fetchSpy).toHaveBeenCalledWith(expect.anything(), { + expect(drupalFetchSpy).toHaveBeenCalledWith(expect.anything(), { withAuth: false, }) }) test("makes authenticated requests with withAuth option", async () => { - const client = new DrupalClient(BASE_URL, { + const drupal = new NextDrupalPages(BASE_URL, { useDefaultResourceTypeEntry: true, auth: `Bearer sample-token`, }) const fetchSpy = spyOnFetch() - jest.spyOn(client, "getAccessToken") - await client.getStaticPathsFromContext( + await drupal.getStaticPathsFromContext( "node--article", { locales: ["en", "es"], @@ -1165,14 +1110,9 @@ describe("getStaticPathsFromContext()", () => { } ) - expect(fetchSpy).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: "Bearer sample-token", - }), - }) - ) + expect( + (fetchSpy.mock.lastCall[1].headers as Headers).get("Authorization") + ).toBe("Bearer sample-token") }) }) @@ -1192,10 +1132,10 @@ describe("preview()", () => { test("turns on preview mode and clears preview data", async () => { const request = new NextApiRequest() const response = new NextApiResponse() - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupalPages(BASE_URL) spyOnFetch({ responseBody: validationPayload }) - await client.preview(request, response) + await drupal.preview(request, response) expect(response.clearPreviewData).toBeCalledTimes(1) expect(response.setPreviewData).toBeCalledWith({ @@ -1209,7 +1149,7 @@ describe("preview()", () => { const logger = mockLogger() const request = new NextApiRequest() const response = new NextApiResponse() - const client = new DrupalClient(BASE_URL, { debug: true, logger }) + const drupal = new NextDrupalPages(BASE_URL, { debug: true, logger }) const status = 403 const message = "mock fail" spyOnFetch({ @@ -1220,7 +1160,7 @@ describe("preview()", () => { }, }) - await client.preview(request, response) + await drupal.preview(request, response) expect(logger.debug).toBeCalledWith( `Draft url validation error: ${message}` @@ -1233,10 +1173,10 @@ describe("preview()", () => { test("does not turn on draft mode by default", async () => { const request = new NextApiRequest() const response = new NextApiResponse() - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupalPages(BASE_URL) spyOnFetch({ responseBody: validationPayload }) - await client.preview(request, response) + await drupal.preview(request, response) expect(response.setDraftMode).toBeCalledTimes(0) @@ -1249,14 +1189,14 @@ describe("preview()", () => { const request = new NextApiRequest() const response = new NextApiResponse() const logger = mockLogger() - const client = new DrupalClient(BASE_URL, { + const drupal = new NextDrupalPages(BASE_URL, { debug: true, logger, }) spyOnFetch({ responseBody: validationPayload }) const options = { enable: true } - await client.preview(request, response, options) + await drupal.preview(request, response, options) expect(response.setDraftMode).toBeCalledWith(options) @@ -1270,7 +1210,7 @@ describe("preview()", () => { test("updates preview mode cookie’s sameSite flag", async () => { const request = new NextApiRequest() const response = new NextApiResponse() - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupalPages(BASE_URL) spyOnFetch({ responseBody: validationPayload }) // Our mock response.setPreviewData() does not set a cookie, so we set one. @@ -1284,7 +1224,7 @@ describe("preview()", () => { const cookies = response.getHeader("Set-Cookie") cookies[0] = cookies[0].replace("SameSite=Lax", "SameSite=None; Secure") - await client.preview(request, response) + await drupal.preview(request, response) expect(response.getHeader).toHaveBeenLastCalledWith("Set-Cookie") expect(response.setHeader).toHaveBeenLastCalledWith("Set-Cookie", cookies) @@ -1295,10 +1235,10 @@ describe("preview()", () => { const request = new NextApiRequest() const response = new NextApiResponse() const logger = mockLogger() - const client = new DrupalClient(BASE_URL, { debug: true, logger }) + const drupal = new NextDrupalPages(BASE_URL, { debug: true, logger }) spyOnFetch({ responseBody: validationPayload }) - await client.preview(request, response) + await drupal.preview(request, response) expect(response.setPreviewData).toBeCalledWith({ resourceVersion, @@ -1313,13 +1253,13 @@ describe("preview()", () => { const request = new NextApiRequest() const response = new NextApiResponse() const logger = mockLogger() - const client = new DrupalClient(BASE_URL, { debug: true, logger }) + const drupal = new NextDrupalPages(BASE_URL, { debug: true, logger }) const message = "mock internal error" response.clearPreviewData = jest.fn(() => { throw new Error(message) }) - await client.preview(request, response) + await drupal.preview(request, response) expect(logger.debug).toHaveBeenLastCalledWith(`Preview failed: ${message}`) expect(response.status).toBeCalledWith(422) @@ -1331,27 +1271,27 @@ describe("previewDisable()", () => { test("clears preview data", async () => { const request = new NextApiRequest() const response = new NextApiResponse() - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupalPages(BASE_URL) - await client.previewDisable(request, response) + await drupal.previewDisable(request, response) expect(response.clearPreviewData).toBeCalledTimes(1) }) test("disables draft mode", async () => { const request = new NextApiRequest() const response = new NextApiResponse() - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupalPages(BASE_URL) - await client.previewDisable(request, response) + await drupal.previewDisable(request, response) expect(response.setDraftMode).toBeCalledWith({ enable: false }) }) test("deletes the draft cookie", async () => { const request = new NextApiRequest() const response = new NextApiResponse() - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupalPages(BASE_URL) - await client.previewDisable(request, response) + await drupal.previewDisable(request, response) const cookies = response.getHeader("Set-Cookie") expect(cookies[cookies.length - 1]).toBe( `${DRAFT_DATA_COOKIE_NAME}=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; SameSite=None; Secure` @@ -1361,9 +1301,9 @@ describe("previewDisable()", () => { test('redirects to "/"', async () => { const request = new NextApiRequest() const response = new NextApiResponse() - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupalPages(BASE_URL) - await client.previewDisable(request, response) + await drupal.previewDisable(request, response) expect(response.writeHead).toBeCalledWith(307, { Location: "/" }) expect(response.end).toBeCalled() }) @@ -1371,7 +1311,7 @@ describe("previewDisable()", () => { describe("translatePathFromContext()", () => { test("translates a path", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupalPages(BASE_URL) const context: GetStaticPropsContext = { params: { @@ -1379,13 +1319,13 @@ describe("translatePathFromContext()", () => { }, } - const path = await client.translatePathFromContext(context) + const path = await drupal.translatePathFromContext(context) expect(path).toMatchSnapshot() }) test("returns null for path not found", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupalPages(BASE_URL) const context: GetStaticPropsContext = { params: { @@ -1393,13 +1333,13 @@ describe("translatePathFromContext()", () => { }, } - const path = await client.translatePathFromContext(context) + const path = await drupal.translatePathFromContext(context) expect(path).toBeNull() }) test("translates a path with pathPrefix", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupalPages(BASE_URL) const context: GetStaticPropsContext = { params: { @@ -1407,13 +1347,13 @@ describe("translatePathFromContext()", () => { }, } - const path = await client.translatePathFromContext(context, { + const path = await drupal.translatePathFromContext(context, { pathPrefix: "recipes", }) expect(path).toMatchSnapshot() - const path2 = await client.translatePathFromContext(context, { + const path2 = await drupal.translatePathFromContext(context, { pathPrefix: "/recipes", }) @@ -1421,17 +1361,17 @@ describe("translatePathFromContext()", () => { }) test("makes un-authenticated requests by default", async () => { - const client = new DrupalClient(BASE_URL) - const fetchSpy = jest.spyOn(client, "fetch") + const drupal = new NextDrupalPages(BASE_URL) + const drupalFetchSpy = jest.spyOn(drupal, "fetch") const context: GetStaticPropsContext = { params: { slug: ["recipes", "deep-mediterranean-quiche"], }, } - await client.translatePathFromContext(context) + await drupal.translatePathFromContext(context) - expect(fetchSpy).toHaveBeenCalledWith( + expect(drupalFetchSpy).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ withAuth: false, @@ -1440,30 +1380,24 @@ describe("translatePathFromContext()", () => { }) test("makes authenticated requests with withAuth option", async () => { - const client = new DrupalClient(BASE_URL, { + const drupal = new NextDrupalPages(BASE_URL, { useDefaultResourceTypeEntry: true, auth: `Bearer sample-token`, }) const fetchSpy = spyOnFetch() - jest.spyOn(client, "getAccessToken") const context: GetStaticPropsContext = { params: { slug: ["deep-mediterranean-quiche"], }, } - await client.translatePathFromContext(context, { + await drupal.translatePathFromContext(context, { pathPrefix: "recipes", withAuth: true, }) - expect(fetchSpy).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: "Bearer sample-token", - }), - }) - ) + expect( + (fetchSpy.mock.lastCall[1].headers as Headers).get("Authorization") + ).toBe("Bearer sample-token") }) }) diff --git a/packages/next-drupal/tests/NextDrupalPages/resource-methods.test.ts b/packages/next-drupal/tests/NextDrupalPages/resource-methods.test.ts new file mode 100644 index 00000000..29ff0e29 --- /dev/null +++ b/packages/next-drupal/tests/NextDrupalPages/resource-methods.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, jest, test } from "@jest/globals" +import { NextDrupalPages } from "../../src" +import { BASE_URL } from "../utils" + +describe("getEntryForResourceType()", () => { + test("returns the JSON:API entry for a resource type", async () => { + const drupal = new NextDrupalPages(BASE_URL) + const getIndexSpy = jest.spyOn(drupal, "getIndex") + + const recipeEntry = await drupal.getEntryForResourceType("node--recipe") + expect(recipeEntry).toMatch(`${BASE_URL}/en/jsonapi/node/recipe`) + expect(getIndexSpy).toHaveBeenCalledTimes(1) + + const articleEntry = await drupal.getEntryForResourceType("node--article") + expect(articleEntry).toMatch(`${BASE_URL}/en/jsonapi/node/article`) + expect(getIndexSpy).toHaveBeenCalledTimes(2) + }) + + test("assembles JSON:API entry without fetching index", async () => { + const drupal = new NextDrupalPages(BASE_URL, { + useDefaultResourceTypeEntry: true, + }) + const getIndexSpy = jest.spyOn(drupal, "getIndex") + + const recipeEntry = await drupal.getEntryForResourceType("node--article") + expect(recipeEntry).toMatch(`${BASE_URL}/jsonapi/node/article`) + expect(getIndexSpy).toHaveBeenCalledTimes(0) + }) + + test("throws an error if resource type does not exist", async () => { + const drupal = new NextDrupalPages(BASE_URL) + + await expect( + drupal.getEntryForResourceType("RESOURCE-DOES-NOT-EXIST") + ).rejects.toThrow("Resource of type 'RESOURCE-DOES-NOT-EXIST' not found.") + }) +}) diff --git a/packages/next-drupal/tests/draft/draft.test.ts b/packages/next-drupal/tests/draft/draft.test.ts index 5a731074..61ab9d51 100644 --- a/packages/next-drupal/tests/draft/draft.test.ts +++ b/packages/next-drupal/tests/draft/draft.test.ts @@ -12,7 +12,7 @@ import { NextRequest } from "next/server" import { DRAFT_DATA_COOKIE_NAME, DRAFT_MODE_COOKIE_NAME, - DrupalClient, + NextDrupal, } from "../../src" import { BASE_URL, spyOnFetch } from "../utils" import { @@ -50,7 +50,7 @@ describe("enableDraftMode()", () => { const request = new NextRequest( `https://example.com/api/draft?${searchParams}` ) - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupal(BASE_URL) const draftModeCookie: ResponseCookie = { name: DRAFT_MODE_COOKIE_NAME, value: "some-secret-key", @@ -60,7 +60,7 @@ describe("enableDraftMode()", () => { test("does not enable draft mode if validation fails", async () => { spyOnFetch({ responseBody: { message: "fail" }, status: 500 }) - const response = await enableDraftMode(request, client) + const response = await enableDraftMode(request, drupal) expect(draftMode().enable).not.toHaveBeenCalled() expect(response).toBeInstanceOf(Response) @@ -70,7 +70,7 @@ describe("enableDraftMode()", () => { test("enables draft mode", async () => { spyOnFetch({ responseBody: validationPayload }) - await enableDraftMode(request, client) + await enableDraftMode(request, drupal) expect(draftMode().enable).toHaveBeenCalled() }) @@ -83,7 +83,7 @@ describe("enableDraftMode()", () => { expect(cookies().get(DRAFT_MODE_COOKIE_NAME).sameSite).toBe("lax") expect(cookies().get(DRAFT_MODE_COOKIE_NAME).secure).toBeFalsy() - await enableDraftMode(request, client) + await enableDraftMode(request, drupal) expect(cookies().get(DRAFT_MODE_COOKIE_NAME).sameSite).toBe("none") expect(cookies().get(DRAFT_MODE_COOKIE_NAME).secure).toBe(true) @@ -93,7 +93,7 @@ describe("enableDraftMode()", () => { spyOnFetch({ responseBody: validationPayload }) expect(cookies().get(DRAFT_DATA_COOKIE_NAME)).toBe(undefined) - await enableDraftMode(request, client) + await enableDraftMode(request, drupal) const cookie = cookies().get(DRAFT_DATA_COOKIE_NAME) // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -111,7 +111,7 @@ describe("enableDraftMode()", () => { test("redirects to the given path", async () => { spyOnFetch({ responseBody: validationPayload }) - await enableDraftMode(request, client) + await enableDraftMode(request, drupal) expect(redirect).toHaveBeenCalledWith(searchParams.get("path")) }) diff --git a/packages/next-drupal/tests/utils/mocks/fetch.ts b/packages/next-drupal/tests/utils/mocks/fetch.ts index b33122ca..425bd32f 100644 --- a/packages/next-drupal/tests/utils/mocks/fetch.ts +++ b/packages/next-drupal/tests/utils/mocks/fetch.ts @@ -52,12 +52,14 @@ function fetchMockImplementation({ } } + const mockedHeaders = new Headers(headers) + if (!mockedHeaders.has("content-type")) { + mockedHeaders.set("content-type", "application/vnd.api+json") + } + return async () => new Response(JSON.stringify(responseBody || {}), { status, - headers: { - "content-type": "application/vnd.api+json", - ...headers, - }, + headers: mockedHeaders, }) } diff --git a/packages/next-drupal/tests/utils/rpc.ts b/packages/next-drupal/tests/utils/rpc.ts index 1b4aae67..7b3e4b7d 100644 --- a/packages/next-drupal/tests/utils/rpc.ts +++ b/packages/next-drupal/tests/utils/rpc.ts @@ -1,27 +1,35 @@ -import { DrupalClient } from "../../src" +import { NextDrupalBase } from "../../src" import { BASE_URL } from "./index" +import type { BaseUrl, NextDrupalBaseOptions } from "../../src" -const client = new DrupalClient(BASE_URL, { +class JsonRpc extends NextDrupalBase { + constructor(baseUrl: BaseUrl, options: NextDrupalBaseOptions = {}) { + super(baseUrl, options) + this.apiPrefix = "/jsonrpc" + } + + async execute(body) { + const endpoint = await jsonRpc.buildEndpoint() + + const response = await jsonRpc.fetch(endpoint, { + method: "POST", + body: JSON.stringify(body), + withAuth: true, + }) + + return response.ok + } +} + +const jsonRpc = new JsonRpc(BASE_URL, { auth: { clientId: process.env["DRUPAL_CLIENT_ID"] as string, clientSecret: process.env["DRUPAL_CLIENT_SECRET"] as string, }, }) -export async function executeRPC(body) { - const url = client.buildUrl("/jsonrpc") - - const response = await client.fetch(url.toString(), { - method: "POST", - body: JSON.stringify(body), - withAuth: true, - }) - - return response.ok -} - export async function toggleDrupalModule(name: string, status = true) { - await executeRPC({ + await jsonRpc.execute({ jsonrpc: "2.0", method: "module.toggle", params: { @@ -33,7 +41,7 @@ export async function toggleDrupalModule(name: string, status = true) { } export async function deleteTestNodes() { - await executeRPC({ + await jsonRpc.execute({ jsonrpc: "2.0", method: "test_content.clean", id: "clean-test-content",