From 3796ef2f353a248a2ce06c50f879a1ef97c8088a 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 NextDrupalFetch class definitions The DrupalClient class has been refactored to inherit from the NextDrupalFetch (base class) and NextDrupal (JsonAPI/App Router class). DrupalClient class contains the methods that are only needed to support Next.js' Pages Router. The getPathFromContext() method has been replaced with the constructPathFromSegment() method for App Router usages. Issue #665 --- packages/next-drupal/src/client.ts | 1514 ----------------- packages/next-drupal/src/draft-constants.ts | 4 + packages/next-drupal/src/draft.ts | 9 +- packages/next-drupal/src/drupal-client.ts | 447 +++++ packages/next-drupal/src/index.ts | 5 +- packages/next-drupal/src/jsonapi-errors.ts | 4 +- packages/next-drupal/src/next-drupal-fetch.ts | 398 +++++ packages/next-drupal/src/next-drupal.ts | 804 +++++++++ packages/next-drupal/src/types/deprecated.ts | 2 +- .../next-drupal/src/types/drupal-client.ts | 34 + packages/next-drupal/src/types/index.ts | 4 +- .../types/{client.ts => next-drupal-fetch.ts} | 87 +- packages/next-drupal/src/types/next-drupal.ts | 67 + packages/next-drupal/src/types/options.ts | 6 +- .../tests/DrupalClient/basic-methods.test.ts | 401 ----- .../tests/DrupalClient/constructor.test.ts | 287 +--- .../tests/DrupalClient/fetch-methods.test.ts | 411 ----- .../DrupalClient/getters-setters.test.ts | 223 --- .../DrupalClient/pages-router-methods.test.ts | 499 +++--- .../__snapshots__/basic-methods.test.ts.snap | 0 .../resource-methods.test.ts.snap | 0 .../tests/NextDrupal/basic-methods.test.ts | 196 +++ .../tests/NextDrupal/constructor.test.ts | 193 +++ .../crud-methods.test.ts | 86 +- .../resource-methods.test.ts | 492 +++--- .../NextDrupalFetch/basic-methods.test.ts | 183 ++ .../tests/NextDrupalFetch/constructor.test.ts | 218 +++ .../NextDrupalFetch/fetch-methods.test.ts | 556 ++++++ .../NextDrupalFetch/getters-setters.test.ts | 264 +++ .../next-drupal/tests/draft/draft.test.ts | 14 +- .../next-drupal/tests/utils/mocks/fetch.ts | 10 +- 31 files changed, 3962 insertions(+), 3456 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/drupal-client.ts create mode 100644 packages/next-drupal/src/next-drupal-fetch.ts create mode 100644 packages/next-drupal/src/next-drupal.ts create mode 100644 packages/next-drupal/src/types/drupal-client.ts rename packages/next-drupal/src/types/{client.ts => next-drupal-fetch.ts} (53%) 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/fetch-methods.test.ts delete mode 100644 packages/next-drupal/tests/DrupalClient/getters-setters.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 (100%) 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 (82%) rename packages/next-drupal/tests/{DrupalClient => NextDrupal}/resource-methods.test.ts (56%) create mode 100644 packages/next-drupal/tests/NextDrupalFetch/basic-methods.test.ts create mode 100644 packages/next-drupal/tests/NextDrupalFetch/constructor.test.ts create mode 100644 packages/next-drupal/tests/NextDrupalFetch/fetch-methods.test.ts create mode 100644 packages/next-drupal/tests/NextDrupalFetch/getters-setters.test.ts diff --git a/packages/next-drupal/src/client.ts b/packages/next-drupal/src/client.ts deleted file mode 100644 index 438c6944..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 slug = searchParams.get("slug") - - this.debug(`Fetching draft url validation for ${slug}.`) - - // Fetch the headless CMS to check if the provided `slug` 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 slug, ${slug}` - : `Validated slug, ${slug}` - ) - - return response - } - - async preview( - request: NextApiRequest, - response: NextApiResponse, - options?: Parameters[0] - ) { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { slug, 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({ slug, resourceVersion, ...draftData }) - )}; Path=/; HttpOnly; SameSite=None; Secure` - ) - } - response.setHeader("Set-Cookie", cookies) - - // We can safely redirect to the slug since this has been validated on the - // server. - response.writeHead(307, { Location: slug }) - - 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/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 a598db72..9e40ae32 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/drupal-client.ts b/packages/next-drupal/src/drupal-client.ts new file mode 100644 index 00000000..46924683 --- /dev/null +++ b/packages/next-drupal/src/drupal-client.ts @@ -0,0 +1,447 @@ +import { Jsona } from "jsona" +import { DRAFT_DATA_COOKIE_NAME } from "./draft-constants" +import { NextDrupal } from "./next-drupal" +import { isClientIdSecretAuth } from "./next-drupal-fetch" +import type { + BaseUrl, + DrupalClientOptions, + DrupalTranslatedPath, + JsonApiOptions, + JsonApiParams, + JsonApiResource, + JsonApiResourceWithPath, + JsonApiWithAuthOption, + JsonDeserializer, + Locale, + PathAlias, + PathPrefix, +} from "./types" +import type { + GetStaticPathsContext, + GetStaticPathsResult, + GetStaticPropsContext, + NextApiRequest, + NextApiResponse, +} from "next" + +export class DrupalClient extends NextDrupal { + private serializer: DrupalClientOptions["serializer"] + + /** + * 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 = {}) { + super(baseUrl, options) + + const { serializer = new Jsona() } = options + + this.serializer = serializer + + this.deserializer = ( + body: Parameters[0], + options: Parameters[1] + ) => this.serializer.deserialize(body, options) + } + + 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: 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 preview( + request: NextApiRequest, + response: NextApiResponse, + options?: Parameters[0] + ) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { slug, 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({ slug, resourceVersion, ...draftData }) + )}; Path=/; HttpOnly; SameSite=None; Secure` + ) + } + response.setHeader("Set-Cookie", cookies) + + // We can safely redirect to the slug since this has been validated on the + // server. + response.writeHead(307, { Location: slug }) + + 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 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/index.ts b/packages/next-drupal/src/index.ts index 860e0b0e..97f3ea4f 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 "./drupal-client" +export * from "./next-drupal-fetch" export * from "./jsonapi-errors" +export * from "./next-drupal" export type * from "./types" diff --git a/packages/next-drupal/src/jsonapi-errors.ts b/packages/next-drupal/src/jsonapi-errors.ts index e2c4c322..d56227b1 100644 --- a/packages/next-drupal/src/jsonapi-errors.ts +++ b/packages/next-drupal/src/jsonapi-errors.ts @@ -17,7 +17,7 @@ export class JsonApiErrors extends Error { errors: JsonApiError[] | string statusCode: number - constructor(errors: JsonApiError[], statusCode: number) { + constructor(errors: JsonApiError[] | string, statusCode: number) { super() this.errors = errors @@ -25,7 +25,7 @@ export class JsonApiErrors extends Error { this.message = 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/next-drupal-fetch.ts b/packages/next-drupal/src/next-drupal-fetch.ts new file mode 100644 index 00000000..e35eb9d8 --- /dev/null +++ b/packages/next-drupal/src/next-drupal-fetch.ts @@ -0,0 +1,398 @@ +import { stringify } from "qs" +import { JsonApiErrors } from "./jsonapi-errors" +import { logger as defaultLogger } from "./logger" +import type { + AccessToken, + AccessTokenScope, + BaseUrl, + FetchOptions, + JsonApiParams, + JsonApiResponse, + NextDrupalAuth, + NextDrupalAuthAccessToken, + NextDrupalAuthClientIdSecret, + NextDrupalAuthUsernamePassword, + NextDrupalFetchOptions, +} from "./types" + +const DEFAULT_API_PREFIX = "/jsonapi" +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 NextDrupalFetch { + baseUrl: BaseUrl + + isDebugEnabled: NextDrupalFetchOptions["debug"] + + logger: NextDrupalFetchOptions["logger"] + + fetcher?: NextDrupalFetchOptions["fetcher"] + + private _headers?: Headers + + private _auth?: NextDrupalFetchOptions["auth"] + + private _apiPrefix: NextDrupalFetchOptions["apiPrefix"] + + private _token?: AccessToken + + private _tokenExpiresOn?: number + + private _tokenRequestDetails?: NextDrupalAuthClientIdSecret + + accessToken?: NextDrupalFetchOptions["accessToken"] + + withAuth?: NextDrupalFetchOptions["withAuth"] + + /** + * Instantiates a new NextDrupalFetch. + * + * const client = new NextDrupalFetch(baseUrl) + * + * @param {baseUrl} baseUrl The baseUrl of your Drupal site. Do not add the /jsonapi suffix. + * @param {options} options Options for NextDrupalFetch. + */ + constructor(baseUrl: BaseUrl, options: NextDrupalFetchOptions = {}) { + if (!baseUrl || typeof baseUrl !== "string") { + throw new Error("The 'baseUrl' param is required.") + } + + const { + apiPrefix = DEFAULT_API_PREFIX, + debug = false, + headers = DEFAULT_HEADERS, + logger = defaultLogger, + withAuth = DEFAULT_WITH_AUTH, + fetcher, + auth, + accessToken, + } = options + + this.baseUrl = baseUrl + this.apiPrefix = apiPrefix + this.isDebugEnabled = !!debug + this.fetcher = fetcher + this.auth = auth + this.headers = headers + this.logger = logger + this.withAuth = withAuth + this.accessToken = accessToken + + this.debug("Debug mode is on.") + } + + set apiPrefix(apiPrefix: NextDrupalFetchOptions["apiPrefix"]) { + this._apiPrefix = apiPrefix.charAt(0) === "/" ? apiPrefix : `/${apiPrefix}` + } + + get apiPrefix() { + return this._apiPrefix + } + + set auth(auth: NextDrupalFetchOptions["auth"]) { + 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(value: NextDrupalFetchOptions["headers"]) { + this._headers = new Headers(value) + } + + 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 (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, + 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) { + // Use stringify instead of URLSearchParams for nested params. + url.search = stringify(params) + } + + return url + } + + 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) + + const result: AccessToken = await response.json() + + this.token = result + + this._tokenRequestDetails = auth + + return result + } + + debug(message) { + this.isDebugEnabled && this.logger.debug(message) + } + + async throwIfJsonErrors(response: Response) { + if (!response?.ok) { + const errors = await this.getErrorsFromResponse(response) + throw new JsonApiErrors(errors, response.status) + } + } + + 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.ts b/packages/next-drupal/src/next-drupal.ts new file mode 100644 index 00000000..12b28067 --- /dev/null +++ b/packages/next-drupal/src/next-drupal.ts @@ -0,0 +1,804 @@ +import { Jsona } from "jsona" +import { stringify } from "qs" +import { NextDrupalFetch } from "./next-drupal-fetch" +import { JsonApiErrors } from "./jsonapi-errors" +import type { + BaseUrl, + JsonDeserializer, + DrupalFile, + DrupalMenuLinkContent, + DrupalTranslatedPath, + DrupalView, + JsonApiCreateFileResourceBody, + JsonApiCreateResourceBody, + JsonApiOptions, + JsonApiResource, + JsonApiResponse, + JsonApiUpdateResourceBody, + JsonApiWithAuthOption, + JsonApiWithCacheOptions, + Locale, + NextDrupalOptions, + PathPrefix, +} from "./types" + +const DEFAULT_FRONT_PAGE = "/home" + +// 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 NextDrupalFetch { + frontPage: NextDrupalOptions["frontPage"] + + deserializer: NextDrupalOptions["deserializer"] + + cache: NextDrupalOptions["cache"] + + throwJsonApiErrors?: NextDrupalOptions["throwJsonApiErrors"] + + useDefaultResourceTypeEntry?: NextDrupalOptions["useDefaultResourceTypeEntry"] + + /** + * 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 NextDrupal. + */ + constructor(baseUrl: BaseUrl, options: NextDrupalOptions = {}) { + super(baseUrl, options) + + const { + deserializer, + cache = null, + frontPage = DEFAULT_FRONT_PAGE, + useDefaultResourceTypeEntry = false, + headers, + throwJsonApiErrors = true, + } = options + + this.deserializer = deserializer ?? useJsonaDeserialize() + this.frontPage = frontPage + this.useDefaultResourceTypeEntry = useDefaultResourceTypeEntry + if (!headers) { + this.headers = DEFAULT_HEADERS + } + this.cache = cache + this.throwJsonApiErrors = throwJsonApiErrors + + // 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 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.throwIfJsonErrors(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.throwIfJsonErrors(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.throwIfJsonErrors(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.throwIfJsonErrors(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.throwIfJsonErrors(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 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"], + }, + ] + + // 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(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 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.throwIfJsonErrors(response) + + 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 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 + } + + 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 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 slug = searchParams.get("slug") + + this.debug(`Fetching draft url validation for ${slug}.`) + + // Fetch the headless CMS to check if the provided `slug` 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 slug, ${slug}` + : `Validated slug, ${slug}` + ) + + return response + } + + 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.throwIfJsonErrors(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.throwIfJsonErrors(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.throwIfJsonErrors(response) + + 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. + throwError(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..700db239 100644 --- a/packages/next-drupal/src/types/deprecated.ts +++ b/packages/next-drupal/src/types/deprecated.ts @@ -1,3 +1,3 @@ -import type { JsonApiOptions } from "./index" +import type { JsonApiOptions } from "./options" export type JsonApiWithLocaleOptions = Omit diff --git a/packages/next-drupal/src/types/drupal-client.ts b/packages/next-drupal/src/types/drupal-client.ts new file mode 100644 index 00000000..811425e5 --- /dev/null +++ b/packages/next-drupal/src/types/drupal-client.ts @@ -0,0 +1,34 @@ +import type { + NextDrupalAuth, + NextDrupalAuthClientIdSecret, + NextDrupalAuthUsernamePassword, + NextDrupalAuthAccessToken, +} from "./next-drupal-fetch" +import type { 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 +} + +export type DrupalClientAuth = NextDrupalAuth + +export type DrupalClientAuthUsernamePassword = NextDrupalAuthUsernamePassword + +export type DrupalClientAuthClientIdSecret = NextDrupalAuthClientIdSecret + +export type DrupalClientAuthAccessToken = NextDrupalAuthAccessToken + +export interface Serializer { + deserialize( + body: Record, + options?: Record + ): unknown +} diff --git a/packages/next-drupal/src/types/index.ts b/packages/next-drupal/src/types/index.ts index f8194aaf..85a4a53c 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-client" export type * from "./drupal" +export type * from "./next-drupal-fetch" +export type * from "./next-drupal" 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-fetch.ts similarity index 53% rename from packages/next-drupal/src/types/client.ts rename to packages/next-drupal/src/types/next-drupal-fetch.ts index 7433458e..ed7c4dc2 100644 --- a/packages/next-drupal/src/types/client.ts +++ b/packages/next-drupal/src/types/next-drupal-fetch.ts @@ -1,4 +1,4 @@ -export type DrupalClientOptions = { +export type NextDrupalFetchOptions = { /** * Set the JSON:API prefix. * @@ -19,16 +19,6 @@ export type DrupalClientOptions = { */ debug?: boolean - /** - * Set the default frontPage. - * - * * **Default value**: `/home` - * * **Required**: *No* - * - * [Documentation](https://next-drupal.org/docs/client/configuration#frontpage) - */ - frontPage?: string - /** * Set custom headers for the fetcher. * @@ -39,15 +29,6 @@ export type DrupalClientOptions = { */ headers?: HeadersInit - /** - * 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 /** * Override the default fetcher. Use this to add your own fetcher ex. axios. * @@ -58,26 +39,6 @@ export type DrupalClientOptions = { */ fetcher?: Fetcher - /** - * Override the default cache. - * - * * **Default value**: `node-cache` - * * **Required**: *No* - * - * [Documentation](https://next-drupal.org/docs/client/configuration#cache) - */ - cache?: DataCache - - /** - * 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 - /** * Override the default logger. You can use this to send logs to a third-party service. * @@ -93,7 +54,7 @@ export type DrupalClientOptions = { * * [Documentation](https://next-drupal.org/docs/client/configuration#auth) */ - auth?: DrupalClientAuth + auth?: NextDrupalAuth /** * Set whether the client should use authenticated requests by default. @@ -105,16 +66,6 @@ export type DrupalClientOptions = { */ 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. * @@ -124,33 +75,28 @@ export type DrupalClientOptions = { * [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 = + | NextDrupalAuthClientIdSecret + | NextDrupalAuthUsernamePassword + | NextDrupalAuthAccessToken | (() => string) | string -export interface DrupalClientAuthUsernamePassword { +export interface NextDrupalAuthUsernamePassword { username: string password: string } -export interface DrupalClientAuthClientIdSecret { +export interface NextDrupalAuthClientIdSecret { clientId: string clientSecret: string url?: string scope?: string } -export type DrupalClientAuthAccessToken = AccessToken +export type NextDrupalAuthAccessToken = AccessToken export type AccessToken = { token_type: string @@ -159,13 +105,7 @@ export type AccessToken = { 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"] @@ -178,10 +118,3 @@ export interface Logger { error(message): void } - -export interface Serializer { - deserialize( - body: Record, - options?: Record - ): unknown -} 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..4ff9cb26 --- /dev/null +++ b/packages/next-drupal/src/types/next-drupal.ts @@ -0,0 +1,67 @@ +import type { TJsonaModel } from "jsona/lib/JsonaTypes" +import type { NextDrupalFetchOptions } from "./next-drupal-fetch" + +export type NextDrupalOptions = NextDrupalFetchOptions & { + /** + * Set the default frontPage. + * + * * **Default value**: `/home` + * * **Required**: *No* + * + * [Documentation](https://next-drupal.org/docs/client/configuration#frontpage) + */ + frontPage?: string + + /** + * 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 + + /** + * Override the default cache. + * + * * **Default value**: `node-cache` + * * **Required**: *No* + * + * [Documentation](https://next-drupal.org/docs/client/configuration#cache) + */ + cache?: DataCache + + /** + * 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 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 +} + +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..bfb19ab3 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-fetch" 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/tests/DrupalClient/basic-methods.test.ts b/packages/next-drupal/tests/DrupalClient/basic-methods.test.ts deleted file mode 100644 index 89cbfbb5..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 slug = "/example" - const searchParams = new URLSearchParams({ - slug, - }) - - 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 ${slug}.` - ) - expect(logger.debug).toHaveBeenCalledWith(`Validated slug, ${slug}`) - - response = await client.validateDraftUrl(searchParams) - expect(response.status).toBe(404) - expect(logger.debug).toHaveBeenCalledWith( - `Could not validate slug, ${slug}` - ) - }) - - test("calls draft-url endpoint", async () => { - const client = new DrupalClient(BASE_URL) - const searchParams = new URLSearchParams({ - slug: "/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({ - slug: "/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({ - slug: "/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 index d1e02243..84d95e79 100644 --- a/packages/next-drupal/tests/DrupalClient/constructor.test.ts +++ b/packages/next-drupal/tests/DrupalClient/constructor.test.ts @@ -1,256 +1,25 @@ 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 { DrupalClient, NextDrupal, NextDrupalFetch } from "../../src" 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) + expect(new DrupalClient(BASE_URL)).toBeInstanceOf(NextDrupal) + expect(new DrupalClient(BASE_URL)).toBeInstanceOf(NextDrupalFetch) }) }) 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) + const drupal = new DrupalClient(BASE_URL) + expect(drupal.serializer).toBeInstanceOf(Jsona) }) test("sets up a custom serializer", () => { @@ -265,52 +34,10 @@ describe("options parameter", () => { }, } - const client = new DrupalClient(BASE_URL, { + const drupal = 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) + expect(drupal.serializer).toBe(customSerializer) }) }) }) 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/DrupalClient/pages-router-methods.test.ts b/packages/next-drupal/tests/DrupalClient/pages-router-methods.test.ts index fa734c51..879361a6 100644 --- a/packages/next-drupal/tests/DrupalClient/pages-router-methods.test.ts +++ b/packages/next-drupal/tests/DrupalClient/pages-router-methods.test.ts @@ -29,9 +29,9 @@ describe("buildStaticPathsFromResources()", () => { ] test("builds static paths from resources", () => { - const client = new DrupalClient(BASE_URL) + const drupal = new DrupalClient(BASE_URL) - expect(client.buildStaticPathsFromResources(resources)).toMatchObject([ + expect(drupal.buildStaticPathsFromResources(resources)).toMatchObject([ { params: { slug: ["blog", "post", "one"], @@ -45,7 +45,7 @@ describe("buildStaticPathsFromResources()", () => { ]) expect( - client.buildStaticPathsFromResources(resources, { locale: "es" }) + drupal.buildStaticPathsFromResources(resources, { locale: "es" }) ).toMatchObject([ { locale: "es", @@ -63,22 +63,22 @@ describe("buildStaticPathsFromResources()", () => { }) test("builds static paths from resources with pathPrefix", () => { - const client = new DrupalClient(BASE_URL) + const drupal = new DrupalClient(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 +115,7 @@ describe("buildStaticPathsFromResources()", () => { }) test('converts frontPage path to "/"', () => { - const client = new DrupalClient(BASE_URL) + const drupal = new DrupalClient(BASE_URL) const resources: Pick[] = [ { @@ -127,7 +127,7 @@ describe("buildStaticPathsFromResources()", () => { }, ] - expect(client.buildStaticPathsFromResources(resources)).toMatchObject([ + expect(drupal.buildStaticPathsFromResources(resources)).toMatchObject([ { params: { slug: [""], @@ -139,11 +139,11 @@ describe("buildStaticPathsFromResources()", () => { describe("buildStaticPathsParamsFromPaths()", () => { test("builds static paths from paths", () => { - const client = new DrupalClient(BASE_URL) + const drupal = new DrupalClient(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 +162,7 @@ describe("buildStaticPathsParamsFromPaths()", () => { ]) expect( - client.buildStaticPathsParamsFromPaths(paths, { locale: "en" }) + drupal.buildStaticPathsParamsFromPaths(paths, { locale: "en" }) ).toMatchObject([ { locale: "en", @@ -186,24 +186,24 @@ describe("buildStaticPathsParamsFromPaths()", () => { }) test("builds static paths from paths with pathPrefix", () => { - const client = new DrupalClient(BASE_URL) + const drupal = new DrupalClient(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" } ) @@ -235,113 +235,83 @@ 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 DrupalClient(BASE_URL, { auth: clientIdSecret, }) + const fetchSpy = spyOnFetch() jest - .spyOn(client, "getAccessToken") + .spyOn(drupal, "getAccessToken") .mockImplementation(async () => accessToken) - await client.getResourceFromContext( - "node--article", - { - preview: false, - }, - { - withAuth: true, - } - ) - - expect(fetchSpy).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: `${accessToken.token_type} ${accessToken.access_token}`, - }), - }) - ) + await drupal.getResourceFromContext("node--article", context, { + withAuth: true, + }) - await client.getResourceFromContext( - "node--article", - { - preview: false, + expect( + (fetchSpy.mock.lastCall[1].headers as Headers).get("Authorization") + ).toBe(`${accessToken.token_type} ${accessToken.access_token}`) + + await drupal.getResourceFromContext("node--article", context, { + withAuth: { + clientId: "foo", + clientSecret: "bar", + scope: "baz", }, - { - withAuth: { - clientId: "foo", - clientSecret: "bar", - scope: "baz", - }, - } - ) + }) - expect(fetchSpy).toHaveBeenLastCalledWith( - expect.anything(), - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: `${accessToken.token_type} ${accessToken.access_token}`, - }), - }) - ) + expect( + (fetchSpy.mock.lastCall[1].headers as Headers).get("Authorization") + ).toBe(`${accessToken.token_type} ${accessToken.access_token}`) }) 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 DrupalClient(BASE_URL, { auth: clientIdSecret, }) const fetchSpy = spyOnFetch() - await client.getResourceFromContext("node--article", { - preview: false, - }) + await drupal.getResourceFromContext("node--article", context) - expect(fetchSpy).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - headers: expect.not.objectContaining({ - Authorization: expect.anything(), - }), - }) - ) + expect( + (fetchSpy.mock.lastCall[1].headers as Headers).has("Authorization") + ).toBeFalsy() - const client2 = new DrupalClient(BASE_URL, { + const drupal2 = new DrupalClient(BASE_URL, { auth: clientIdSecret, withAuth: true, }) jest - .spyOn(client2, "getAccessToken") + .spyOn(drupal2, "getAccessToken") .mockImplementation(async () => accessToken) - await client2.getResourceFromContext("node--article", { - preview: false, - }) + await drupal2.getResourceFromContext("node--article", context) - expect(fetchSpy).toHaveBeenLastCalledWith( - expect.anything(), - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: `${accessToken.token_type} ${accessToken.access_token}`, - }), - }) - ) + expect( + (fetchSpy.mock.lastCall[1].headers as Headers).get("Authorization") + ).toBe(`${accessToken.token_type} ${accessToken.access_token}`) }) test("should NOT use the global auth if in preview", async () => { - const client = new DrupalClient(BASE_URL, { + const drupal = new DrupalClient(BASE_URL, { auth: clientIdSecret, withAuth: true, }) - const fetchSpy = jest.spyOn(client, "fetch") + const drupalFetchSpy = jest.spyOn(drupal, "fetch") spyOnFetch() - await client.getResourceFromContext("node--article", { + await drupal.getResourceFromContext("node--article", { + ...context, preview: true, }) - expect(fetchSpy).toHaveBeenCalledWith( + expect(drupalFetchSpy).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ withAuth: null, @@ -350,13 +320,14 @@ describe("getAuthFromContextAndOptions()", () => { }) 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 DrupalClient(BASE_URL, { auth: clientIdSecret, }) - const fetchSpy = jest.spyOn(client, "fetch") + const drupalFetchSpy = jest.spyOn(drupal, "fetch") spyOnFetch() - await client.getResourceFromContext("node--article", { + await drupal.getResourceFromContext("node--article", { + ...context, preview: true, previewData: { plugin: "simple_oauth", @@ -364,7 +335,7 @@ describe("getAuthFromContextAndOptions()", () => { }, }) - expect(fetchSpy).toHaveBeenCalledWith( + expect(drupalFetchSpy).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ withAuth: { @@ -377,17 +348,18 @@ describe("getAuthFromContextAndOptions()", () => { }) 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 DrupalClient(BASE_URL, { auth: { ...clientIdSecret, scope: "administrator", }, withAuth: true, }) - const fetchSpy = jest.spyOn(client, "fetch") + const drupalFetchSpy = jest.spyOn(drupal, "fetch") spyOnFetch() - await client.getResourceFromContext("node--article", { + await drupal.getResourceFromContext("node--article", { + ...context, preview: true, previewData: { plugin: "simple_oauth", @@ -395,7 +367,7 @@ describe("getAuthFromContextAndOptions()", () => { }, }) - expect(fetchSpy).toHaveBeenCalledWith( + expect(drupalFetchSpy).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ withAuth: { @@ -408,12 +380,13 @@ describe("getAuthFromContextAndOptions()", () => { }) 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 DrupalClient(BASE_URL, { auth: clientIdSecret, }) const fetchSpy = spyOnFetch() - await client.getResourceFromContext("node--article", { + await drupal.getResourceFromContext("node--article", { + ...context, preview: true, previewData: { plugin: "jwt", @@ -421,18 +394,13 @@ describe("getAuthFromContextAndOptions()", () => { }, }) - expect(fetchSpy).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: `Bearer example-token`, - }), - }) - ) + expect( + (fetchSpy.mock.lastCall[1].headers as Headers).get("Authorization") + ).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 DrupalClient(BASE_URL, { auth: { ...clientIdSecret, scope: "administrator", @@ -441,7 +409,8 @@ describe("getAuthFromContextAndOptions()", () => { }) const fetchSpy = spyOnFetch() - await client.getResourceFromContext("node--article", { + await drupal.getResourceFromContext("node--article", { + ...context, preview: true, previewData: { plugin: "jwt", @@ -449,23 +418,18 @@ describe("getAuthFromContextAndOptions()", () => { }, }) - expect(fetchSpy).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: `Bearer example-token`, - }), - }) - ) + expect( + (fetchSpy.mock.lastCall[1].headers as Headers).get("Authorization") + ).toBe(`Bearer example-token`) }) }) describe("getPathFromContext()", () => { test("returns a path from context", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new DrupalClient(BASE_URL) expect( - client.getPathFromContext({ + drupal.getPathFromContext({ params: { slug: ["foo"], }, @@ -473,7 +437,7 @@ describe("getPathFromContext()", () => { ).toEqual("/foo") expect( - client.getPathFromContext({ + drupal.getPathFromContext({ params: { slug: ["foo", "bar"], }, @@ -481,7 +445,7 @@ describe("getPathFromContext()", () => { ).toEqual("/foo/bar") expect( - client.getPathFromContext({ + drupal.getPathFromContext({ locale: "en", defaultLocale: "es", params: { @@ -491,17 +455,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 +473,7 @@ describe("getPathFromContext()", () => { ).toEqual("/front") expect( - client.getPathFromContext({ + drupal.getPathFromContext({ locale: "es", defaultLocale: "en", params: { @@ -520,10 +484,10 @@ describe("getPathFromContext()", () => { }) test("returns a path from context with pathPrefix", () => { - const client = new DrupalClient(BASE_URL) + const drupal = new DrupalClient(BASE_URL) expect( - client.getPathFromContext( + drupal.getPathFromContext( { params: { slug: ["bar", "baz"], @@ -536,7 +500,7 @@ describe("getPathFromContext()", () => { ).toEqual("/foo/bar/baz") expect( - client.getPathFromContext( + drupal.getPathFromContext( { params: { slug: ["bar", "baz"], @@ -549,7 +513,7 @@ describe("getPathFromContext()", () => { ).toEqual("/foo/bar/baz") expect( - client.getPathFromContext( + drupal.getPathFromContext( { locale: "en", defaultLocale: "en", @@ -564,7 +528,7 @@ describe("getPathFromContext()", () => { ).toEqual("/foo/bar/baz") expect( - client.getPathFromContext( + drupal.getPathFromContext( { locale: "es", defaultLocale: "en", @@ -579,7 +543,7 @@ describe("getPathFromContext()", () => { ).toEqual("/es/foo/bar/baz") expect( - client.getPathFromContext( + drupal.getPathFromContext( { locale: "es", defaultLocale: "en", @@ -591,12 +555,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 +572,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 DrupalClient(BASE_URL) - const path = client.getPathFromContext({ + const path = drupal.getPathFromContext({ params: { slug: ["path&with^punc&in$path"], }, @@ -635,7 +599,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 +607,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 DrupalClient(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 DrupalClient(BASE_URL) const context: GetStaticPropsContext = { locale: "en", defaultLocale: "en", } - const articles = await client.getResourceCollectionFromContext( + const articles = await drupal.getResourceCollectionFromContext( "node--article", context, { @@ -671,14 +635,14 @@ describe("getResourceCollectionFromContext()", () => { }) test("fetches a resource collection using locale", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new DrupalClient(BASE_URL) const context: GetStaticPropsContext = { locale: "es", defaultLocale: "en", } - const articles = await client.getResourceCollectionFromContext( + const articles = await drupal.getResourceCollectionFromContext( "node--article", context, { @@ -694,14 +658,14 @@ describe("getResourceCollectionFromContext()", () => { }) test("fetches raw data", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new DrupalClient(BASE_URL) const context: GetStaticPropsContext = { locale: "en", defaultLocale: "en", } - const recipes = await client.getResourceCollectionFromContext( + const recipes = await drupal.getResourceCollectionFromContext( "node--recipe", context, { @@ -717,7 +681,7 @@ describe("getResourceCollectionFromContext()", () => { }) test("throws an error for invalid resource type", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new DrupalClient(BASE_URL) const context: GetStaticPropsContext = { locale: "en", @@ -725,7 +689,7 @@ describe("getResourceCollectionFromContext()", () => { } await expect( - client.getResourceCollectionFromContext( + drupal.getResourceCollectionFromContext( "RESOURCE-DOES-NOT-EXIST", context ) @@ -733,7 +697,7 @@ describe("getResourceCollectionFromContext()", () => { }) test("throws an error for invalid params", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new DrupalClient(BASE_URL) const context: GetStaticPropsContext = { locale: "en", @@ -741,7 +705,7 @@ describe("getResourceCollectionFromContext()", () => { } await expect( - client.getResourceCollectionFromContext( + drupal.getResourceCollectionFromContext( "node--recipe", context, { @@ -756,56 +720,51 @@ 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 DrupalClient(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 DrupalClient(BASE_URL, { useDefaultResourceTypeEntry: true, auth: `Bearer sample-token`, }) const fetchSpy = spyOnFetch() - jest.spyOn(client, "getAccessToken") + jest.spyOn(drupal, "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 DrupalClient(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 +773,13 @@ describe("getResourceFromContext()", () => { }) test("fetches a resource from context with params", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new DrupalClient(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 +793,7 @@ describe("getResourceFromContext()", () => { }) test("fetches a resource from context using locale", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new DrupalClient(BASE_URL) const context: GetStaticPropsContext = { params: { slug: ["recipes", "quiche-mediterrĂ¡neo-profundo"], @@ -842,7 +801,7 @@ describe("getResourceFromContext()", () => { locale: "es", defaultLocale: "en", } - const recipe = await client.getResourceFromContext( + const recipe = await drupal.getResourceFromContext( "node--recipe", context, { @@ -856,14 +815,14 @@ describe("getResourceFromContext()", () => { }) test("fetches raw data", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new DrupalClient(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 +837,7 @@ describe("getResourceFromContext()", () => { }) test("fetches a resource from context by revision", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new DrupalClient(BASE_URL) const context: GetStaticPropsContext = { params: { slug: ["recipes", "quiche-mediterrĂ¡neo-profundo"], @@ -886,7 +845,7 @@ describe("getResourceFromContext()", () => { locale: "es", defaultLocale: "en", } - const recipe = await client.getResourceFromContext( + const recipe = await drupal.getResourceFromContext( "node--recipe", context, { @@ -898,7 +857,7 @@ describe("getResourceFromContext()", () => { context.previewData = { resourceVersion: "rel:latest-version" } - const latestRevision = await client.getResourceFromContext( + const latestRevision = await drupal.getResourceFromContext( "node--recipe", context, { @@ -914,7 +873,7 @@ describe("getResourceFromContext()", () => { }) test("throws an error for invalid revision", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new DrupalClient(BASE_URL) const context: GetStaticPropsContext = { previewData: { resourceVersion: "id:-11", @@ -925,7 +884,7 @@ describe("getResourceFromContext()", () => { } await expect( - client.getResourceFromContext("node--recipe", context, { + drupal.getResourceFromContext("node--recipe", context, { params: { "fields[node--recipe]": "drupal_internal__vid", }, @@ -936,7 +895,7 @@ describe("getResourceFromContext()", () => { }) test("throws an error if revision access is forbidden", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new DrupalClient(BASE_URL) const context: GetStaticPropsContext = { previewData: { @@ -948,7 +907,7 @@ describe("getResourceFromContext()", () => { } await expect( - client.getResourceFromContext("node--recipe", context, { + drupal.getResourceFromContext("node--recipe", context, { params: { "fields[node--recipe]": "title", }, @@ -959,16 +918,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 DrupalClient(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 +936,12 @@ describe("getResourceFromContext()", () => { }) test("makes authenticated requests with withAuth option", async () => { - const client = new DrupalClient(BASE_URL, { + const drupal = new DrupalClient(BASE_URL, { useDefaultResourceTypeEntry: true, auth: `Bearer sample-token`, }) const fetchSpy = spyOnFetch() - jest.spyOn(client, "getAccessToken") + jest.spyOn(drupal, "getAccessToken") const context: GetStaticPropsContext = { params: { @@ -990,27 +949,22 @@ 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 DrupalClient(BASE_URL, { useDefaultResourceTypeEntry: true, auth: `Bearer sample-token`, }) const fetchSpy = spyOnFetch() - jest.spyOn(client, "getAccessToken") + jest.spyOn(drupal, "getAccessToken") const context: GetStaticPropsContext = { preview: true, @@ -1023,22 +977,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 DrupalClient(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 +995,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 +1007,9 @@ describe("getResourceFromContext()", () => { describe("getSearchIndexFromContext()", () => { test("calls getSearchIndex() with context data", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new DrupalClient(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 +1018,7 @@ describe("getSearchIndexFromContext()", () => { deserialize: true, } - await client.getSearchIndexFromContext( + await drupal.getSearchIndexFromContext( name, { locale, defaultLocale }, options @@ -1085,17 +1034,17 @@ describe("getSearchIndexFromContext()", () => { describe("getStaticPathsFromContext()", () => { test("returns static paths from context", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new DrupalClient(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 DrupalClient(BASE_URL) - const paths = await client.getStaticPathsFromContext("node--article", { + const paths = await drupal.getStaticPathsFromContext("node--article", { locales: ["en", "es"], defaultLocale: "en", }) @@ -1104,9 +1053,9 @@ describe("getStaticPathsFromContext()", () => { }) test("returns static paths for multiple resource types from context", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new DrupalClient(BASE_URL) - const paths = await client.getStaticPathsFromContext( + const paths = await drupal.getStaticPathsFromContext( ["node--article", "node--recipe"], { locales: ["en", "es"], @@ -1118,9 +1067,9 @@ describe("getStaticPathsFromContext()", () => { }) test("returns static paths from context with params", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new DrupalClient(BASE_URL) - const paths = await client.getStaticPathsFromContext( + const paths = await drupal.getStaticPathsFromContext( "node--article", {}, { @@ -1134,27 +1083,27 @@ 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 DrupalClient(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 DrupalClient(BASE_URL, { useDefaultResourceTypeEntry: true, auth: `Bearer sample-token`, }) const fetchSpy = spyOnFetch() - jest.spyOn(client, "getAccessToken") + jest.spyOn(drupal, "getAccessToken") - await client.getStaticPathsFromContext( + await drupal.getStaticPathsFromContext( "node--article", { locales: ["en", "es"], @@ -1165,14 +1114,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 +1136,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 DrupalClient(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 +1153,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 DrupalClient(BASE_URL, { debug: true, logger }) const status = 403 const message = "mock fail" spyOnFetch({ @@ -1220,7 +1164,7 @@ describe("preview()", () => { }, }) - await client.preview(request, response) + await drupal.preview(request, response) expect(logger.debug).toBeCalledWith( `Draft url validation error: ${message}` @@ -1233,10 +1177,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 DrupalClient(BASE_URL) spyOnFetch({ responseBody: validationPayload }) - await client.preview(request, response) + await drupal.preview(request, response) expect(response.setDraftMode).toBeCalledTimes(0) @@ -1249,14 +1193,14 @@ describe("preview()", () => { const request = new NextApiRequest() const response = new NextApiResponse() const logger = mockLogger() - const client = new DrupalClient(BASE_URL, { + const drupal = new DrupalClient(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 +1214,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 DrupalClient(BASE_URL) spyOnFetch({ responseBody: validationPayload }) // Our mock response.setPreviewData() does not set a cookie, so we set one. @@ -1284,7 +1228,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 +1239,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 DrupalClient(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 +1257,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 DrupalClient(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 +1275,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 DrupalClient(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 DrupalClient(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 DrupalClient(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 +1305,9 @@ describe("previewDisable()", () => { test('redirects to "/"', async () => { const request = new NextApiRequest() const response = new NextApiResponse() - const client = new DrupalClient(BASE_URL) + const drupal = new DrupalClient(BASE_URL) - await client.previewDisable(request, response) + await drupal.previewDisable(request, response) expect(response.writeHead).toBeCalledWith(307, { Location: "/" }) expect(response.end).toBeCalled() }) @@ -1371,7 +1315,7 @@ describe("previewDisable()", () => { describe("translatePathFromContext()", () => { test("translates a path", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new DrupalClient(BASE_URL) const context: GetStaticPropsContext = { params: { @@ -1379,13 +1323,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 DrupalClient(BASE_URL) const context: GetStaticPropsContext = { params: { @@ -1393,13 +1337,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 DrupalClient(BASE_URL) const context: GetStaticPropsContext = { params: { @@ -1407,13 +1351,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 +1365,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 DrupalClient(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 +1384,25 @@ describe("translatePathFromContext()", () => { }) test("makes authenticated requests with withAuth option", async () => { - const client = new DrupalClient(BASE_URL, { + const drupal = new DrupalClient(BASE_URL, { useDefaultResourceTypeEntry: true, auth: `Bearer sample-token`, }) const fetchSpy = spyOnFetch() - jest.spyOn(client, "getAccessToken") + jest.spyOn(drupal, "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/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 100% 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 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..b4566041 --- /dev/null +++ b/packages/next-drupal/tests/NextDrupal/basic-methods.test.ts @@ -0,0 +1,196 @@ +import { afterEach, describe, expect, jest, test } from "@jest/globals" +import { NextDrupal } from "../../src" +import { BASE_URL, mockLogger, spyOnFetch, spyOnFetchOnce } 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 = drupal.buildUrl( + "/jsonapi/node/article/52837ad0-f218-46bd-a106-5710336b7053", + { + include: "field_tags", + } + ) + + const response = await drupal.fetch(url.toString()) + 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 = drupal.buildUrl("/jsonapi/node/article", { + getQueryObject: () => ({ + "fields[node--article]": "title", + }), + }) + + const response = await drupal.fetch(url.toString()) + 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 = drupal.buildUrl( + "/jsonapi/node/article/52837ad0-f218-46bd-a106-5710336b7053" + ) + + const response = await drupal.fetch(url.toString()) + 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("throwError()", () => { + test("throws the error", () => { + const drupal = new NextDrupal(BASE_URL) + expect(() => { + drupal.throwError(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.throwError(new Error("Example error")) + }).not.toThrow() + expect(logger.error).toHaveBeenCalledWith(new Error("Example error")) + }) +}) + +describe("validateDraftUrl()", () => { + test("outputs debug messages", async () => { + const logger = mockLogger() + const drupal = new NextDrupal(BASE_URL, { + debug: true, + logger, + }) + const slug = "/example" + const searchParams = new URLSearchParams({ + slug, + }) + + 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 ${slug}.` + ) + expect(logger.debug).toHaveBeenCalledWith(`Validated slug, ${slug}`) + + response = await drupal.validateDraftUrl(searchParams) + expect(response.status).toBe(404) + expect(logger.debug).toHaveBeenCalledWith( + `Could not validate slug, ${slug}` + ) + }) + + test("calls draft-url endpoint", async () => { + const drupal = new NextDrupal(BASE_URL) + const searchParams = new URLSearchParams({ + slug: "/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 drupal = new NextDrupal(BASE_URL) + const searchParams = new URLSearchParams({ + slug: "/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 drupal = new NextDrupal(BASE_URL) + const searchParams = new URLSearchParams({ + slug: "/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/NextDrupal/constructor.test.ts b/packages/next-drupal/tests/NextDrupal/constructor.test.ts new file mode 100644 index 00000000..3a3eb4dc --- /dev/null +++ b/packages/next-drupal/tests/NextDrupal/constructor.test.ts @@ -0,0 +1,193 @@ +import { Jsona } from "jsona" +import { + afterEach, + beforeEach, + describe, + expect, + jest, + test, +} from "@jest/globals" +import { NextDrupal, NextDrupalFetch } 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(NextDrupalFetch) + }) +}) + +describe("options parameter", () => { + 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("useDefaultResourceTypeEntry", () => { + test("defaults to `false`", () => { + const drupal = new NextDrupal(BASE_URL) + expect(drupal.useDefaultResourceTypeEntry).toBe(false) + }) + + test("can be set to `true`", () => { + const drupal = new NextDrupal(BASE_URL, { + useDefaultResourceTypeEntry: true, + }) + expect(drupal.useDefaultResourceTypeEntry).toBe(true) + }) + }) +}) diff --git a/packages/next-drupal/tests/DrupalClient/crud-methods.test.ts b/packages/next-drupal/tests/NextDrupal/crud-methods.test.ts similarity index 82% rename from packages/next-drupal/tests/DrupalClient/crud-methods.test.ts rename to packages/next-drupal/tests/NextDrupal/crud-methods.test.ts index cb8e4015..39d28e92 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,7 +202,7 @@ describe("createFileResource()", () => { test("constructs the API path from body and options", async () => { const logger = mockLogger() - const client = new DrupalClient("https://example.com", { + const drupal = new NextDrupal("https://example.com", { useDefaultResourceTypeEntry: true, debug: true, logger, @@ -210,7 +210,7 @@ describe("createFileResource()", () => { 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 +224,13 @@ describe("createFileResource()", () => { }) test("constructs the API path using non-default locale", async () => { - const client = new DrupalClient("https://example.com", { + const drupal = new NextDrupal("https://example.com", { useDefaultResourceTypeEntry: true, }) 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 +243,12 @@ describe("createFileResource()", () => { }) test("returns the deserialized data", async () => { - const client = new DrupalClient(BASE_URL, { + const drupal = new NextDrupal(BASE_URL, { useDefaultResourceTypeEntry: true, }) spyOnFetch({ responseBody: mockResponseData }) - const result = await client.createFileResource("ignored", mockBody, { + const result = await drupal.createFileResource("ignored", mockBody, { withAuth: false, }) @@ -257,12 +257,12 @@ describe("createFileResource()", () => { }) test("optionally returns the raw data", async () => { - const client = new DrupalClient(BASE_URL, { + const drupal = new NextDrupal(BASE_URL, { useDefaultResourceTypeEntry: true, }) spyOnFetch({ responseBody: mockResponseData }) - const result = await client.createFileResource("ignored", mockBody, { + const result = await drupal.createFileResource("ignored", mockBody, { withAuth: false, deserialize: false, }) @@ -274,7 +274,7 @@ describe("createFileResource()", () => { }) test("throws error if response is not ok", async () => { - const client = new DrupalClient(BASE_URL, { + const drupal = new NextDrupal(BASE_URL, { useDefaultResourceTypeEntry: true, }) const message = "mock error" @@ -287,7 +287,7 @@ describe("createFileResource()", () => { }) await expect( - client.createFileResource("ignored", mockBody, { + drupal.createFileResource("ignored", mockBody, { withAuth: false, }) ).rejects.toThrow(message) @@ -296,13 +296,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 +316,7 @@ describe("updateResource()", () => { } ) - const updatedArticle = await client.updateResource( + const updatedArticle = await drupal.updateResource( "node--article", article.id, { @@ -340,12 +340,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 +354,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 +363,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 +396,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 +412,7 @@ describe("updateResource()", () => { }) await expect( - client.updateResource("node--article", article.id, { + drupal.updateResource("node--article", article.id, { data: { attributes: { title: null, @@ -425,14 +425,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 +441,7 @@ describe("updateResource()", () => { }) await expect( - client.updateResource("node--article", article.id, { + drupal.updateResource("node--article", article.id, { data: { attributes: { body: { @@ -455,7 +455,7 @@ describe("updateResource()", () => { ) await expect( - client.updateResource("node--article", article.id, { + drupal.updateResource("node--article", article.id, { data: { attributes: { body: { @@ -473,14 +473,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 +488,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 +508,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 56% rename from packages/next-drupal/tests/DrupalClient/resource-methods.test.ts rename to packages/next-drupal/tests/NextDrupal/resource-methods.test.ts index 51fc43b1..a7629a5b 100644 --- a/packages/next-drupal/tests/DrupalClient/resource-methods.test.ts +++ b/packages/next-drupal/tests/NextDrupal/resource-methods.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, jest, test } from "@jest/globals" -import { DrupalClient } from "../../src" +import { NextDrupal } from "../../src" import { BASE_URL, mocks, spyOnFetch } from "../utils" import type { DrupalNode, DrupalSearchApiJsonApiResponse } from "../../src" @@ -15,57 +15,57 @@ describe("buildMenuTree()", () => { 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") + const drupal = new NextDrupal(BASE_URL) + const getIndexSpy = jest.spyOn(drupal, "getIndex") - const recipeEntry = await client.getEntryForResourceType("node--recipe") + const recipeEntry = await drupal.getEntryForResourceType("node--recipe") expect(recipeEntry).toMatch(`${BASE_URL}/en/jsonapi/node/recipe`) expect(getIndexSpy).toHaveBeenCalledTimes(1) - const articleEntry = await client.getEntryForResourceType("node--article") + 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 client = new DrupalClient(BASE_URL, { + const drupal = new NextDrupal(BASE_URL, { useDefaultResourceTypeEntry: true, }) - const getIndexSpy = jest.spyOn(client, "getIndex") + const getIndexSpy = jest.spyOn(drupal, "getIndex") - const recipeEntry = await client.getEntryForResourceType("node--article") + 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 client = new DrupalClient(BASE_URL) + const drupal = new NextDrupal(BASE_URL) await expect( - client.getEntryForResourceType("RESOURCE-DOES-NOT-EXIST") + drupal.getEntryForResourceType("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 +73,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 +92,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 +104,44 @@ 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 client.getMenu("main") - expect(fetchSpy).toHaveBeenCalledWith(expect.anything(), { + await drupal.getMenu("main") + + expect(drupalFetchSpy).toHaveBeenCalledWith(expect.anything(), { withAuth: false, }) }) test("makes authenticated requests with withAuth option", async () => { - const client = new DrupalClient(BASE_URL, { + const drupal = new NextDrupal(BASE_URL, { useDefaultResourceTypeEntry: true, auth: `Bearer sample-token`, }) const fetchSpy = spyOnFetch() - jest.spyOn(client, "getAccessToken") + jest.spyOn(drupal, "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 +150,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 +165,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 +182,10 @@ describe("getResource()", () => { }) test("fetches raw data", 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", { @@ -200,8 +196,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 +206,7 @@ describe("getResource()", () => { }, } ) - const latestRevision = await client.getResource( + const latestRevision = await drupal.getResource( "node--recipe", "71e04ead-4cc7-416c-b9ca-60b635fdc50f", { @@ -227,10 +223,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 +242,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 +261,10 @@ describe("getResource()", () => { }) test("throws an error for invalid resource type", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupal(BASE_URL) await expect( - client.getResource( + drupal.getResource( "RESOURCE-DOES-NOT-EXIST", "71e04ead-4cc7-416c-b9ca-60b635fdc50f" ) @@ -276,10 +272,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 +290,27 @@ 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, { + const drupal = new NextDrupal(BASE_URL, { useDefaultResourceTypeEntry: true, auth: `Bearer sample-token`, }) const fetchSpy = spyOnFetch() - jest.spyOn(client, "getAccessToken") + jest.spyOn(drupal, "getAccessToken") - await client.getResource( + await drupal.getResource( "node--recipe", "71e04ead-4cc7-416c-b9ca-60b635fdc50f", { @@ -322,31 +318,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 +346,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 +362,18 @@ describe("getResourceByPath()", () => { }) test("fetches raw data", 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", { 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 +381,7 @@ describe("getResourceByPath()", () => { }, } ) - const latestRevision = await client.getResourceByPath( + const latestRevision = await drupal.getResourceByPath( "/recipes/deep-mediterranean-quiche", { params: { @@ -406,10 +397,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 +415,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 +433,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 +458,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 +477,42 @@ 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, }) 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 +522,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 +538,9 @@ describe("getResourceCollection()", () => { }) test("fetches raw data", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupal(BASE_URL) - const recipes = await client.getResourceCollection("node--recipe", { + const recipes = await drupal.getResourceCollection("node--recipe", { deserialize: false, params: { "fields[node--recipe]": "title", @@ -564,18 +552,18 @@ describe("getResourceCollection()", () => { }) test("throws an error for invalid resource type", async () => { - const client = new DrupalClient(BASE_URL) + const drupal = new NextDrupal(BASE_URL) 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 +574,38 @@ 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, { + const drupal = new NextDrupal(BASE_URL, { useDefaultResourceTypeEntry: true, auth: `Bearer sample-token`, }) const fetchSpy = spyOnFetch() - jest.spyOn(client, "getAccessToken") + jest.spyOn(drupal, "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 +615,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 +629,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 +647,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 +661,55 @@ 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, { + const drupal = new NextDrupal(BASE_URL, { useDefaultResourceTypeEntry: true, auth: `Bearer sample-token`, }) const fetchSpy = spyOnFetch() - jest.spyOn(client, "getAccessToken") + jest.spyOn(drupal, "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 +719,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 +733,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 +748,39 @@ 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, { + const drupal = new NextDrupal(BASE_URL, { useDefaultResourceTypeEntry: true, auth: `Bearer sample-token`, }) const fetchSpy = spyOnFetch() - jest.spyOn(client, "getAccessToken") + jest.spyOn(drupal, "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 +788,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 +802,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 +824,133 @@ describe("translatePath()", () => { }) test("makes authenticated requests with withAuth option", async () => { - const client = new DrupalClient(BASE_URL, { + const drupal = new NextDrupal(BASE_URL, { useDefaultResourceTypeEntry: true, auth: `Bearer sample-token`, }) const fetchSpy = spyOnFetch() - jest.spyOn(client, "getAccessToken") + jest.spyOn(drupal, "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") + }) +}) + +describe("constructPathFromSegment()", () => { + const frontPage = "/home" + const drupal = new NextDrupal(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 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("addLocalePrefix()", () => { + const drupal = new NextDrupal(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") }) }) diff --git a/packages/next-drupal/tests/NextDrupalFetch/basic-methods.test.ts b/packages/next-drupal/tests/NextDrupalFetch/basic-methods.test.ts new file mode 100644 index 00000000..16331764 --- /dev/null +++ b/packages/next-drupal/tests/NextDrupalFetch/basic-methods.test.ts @@ -0,0 +1,183 @@ +import { afterEach, describe, expect, jest, test } from "@jest/globals" +import { NextDrupalFetch, JsonApiErrors } from "../../src" +import { BASE_URL, mockLogger } from "../utils" +import type { JsonApiError } from "../../src" + +jest.setTimeout(10000) + +afterEach(() => { + jest.restoreAllMocks() +}) + +describe("buildUrl()", () => { + const drupal = new NextDrupalFetch(BASE_URL) + + test("builds a url", () => { + expect(drupal.buildUrl("http://example.com").toString()).toEqual( + "http://example.com/" + ) + }) + + test("builds a relative url", () => { + expect(drupal.buildUrl("/foo").toString()).toEqual(`${BASE_URL}/foo`) + }) + + test("builds a url with params", () => { + 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("builds a url from object (DrupalJsonApiParams)", () => { + const params = { + getQueryObject: () => ({ + sort: "-created", + "fields[node--article]": "title,path", + }), + } + + expect(drupal.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 drupal = new NextDrupalFetch(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 NextDrupalFetch(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 NextDrupalFetch(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.", + }, + ] as JsonApiError[], + } + 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 NextDrupalFetch(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 + ) + }) +}) diff --git a/packages/next-drupal/tests/NextDrupalFetch/constructor.test.ts b/packages/next-drupal/tests/NextDrupalFetch/constructor.test.ts new file mode 100644 index 00000000..d266a945 --- /dev/null +++ b/packages/next-drupal/tests/NextDrupalFetch/constructor.test.ts @@ -0,0 +1,218 @@ +import { afterEach, describe, expect, jest, test } from "@jest/globals" +import { NextDrupalFetch } 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 NextDrupalFetch()).toThrow( + "The 'baseUrl' param is required." + ) + + // @ts-ignore + expect(() => new NextDrupalFetch({})).toThrow( + "The 'baseUrl' param is required." + ) + }) + + test("announces debug mode when turned on", () => { + const consoleSpy = jest.spyOn(console, "debug").mockImplementation(() => { + // + }) + + new NextDrupalFetch(BASE_URL, { + debug: true, + }) + + expect(consoleSpy).toHaveBeenCalledWith( + DEBUG_MESSAGE_PREFIX, + "Debug mode is on." + ) + }) + + test("returns a NextDrupalFetch", () => { + expect(new NextDrupalFetch(BASE_URL)).toBeInstanceOf(NextDrupalFetch) + }) +}) + +describe("options parameter", () => { + describe("accessToken", () => { + test("defaults to `undefined`", () => { + const drupal = new NextDrupalFetch(BASE_URL) + expect(drupal.accessToken).toBe(undefined) + }) + + test("sets the accessToken", async () => { + const accessToken = mocks.auth.accessToken + + const drupal = new NextDrupalFetch(BASE_URL, { + accessToken, + }) + + expect(drupal.accessToken).toEqual(accessToken) + }) + }) + + describe("apiPrefix", () => { + test('defaults to "/jsonapi"', () => { + const drupal = new NextDrupalFetch(BASE_URL) + expect(drupal.apiPrefix).toBe("/jsonapi") + }) + + test("sets the apiPrefix", () => { + const customEndPoint = "/customapi" + const drupal = new NextDrupalFetch(BASE_URL, { + apiPrefix: customEndPoint, + }) + expect(drupal.apiPrefix).toBe(customEndPoint) + }) + }) + + describe("auth", () => { + test("defaults to `undefined`", () => { + const drupal = new NextDrupalFetch(BASE_URL) + expect(drupal.auth).toBe(undefined) + }) + + test("sets the auth credentials", () => { + const auth: NextDrupalAuth = { + username: "example", + password: "pw", + } + const drupal = new NextDrupalFetch(BASE_URL, { + auth, + }) + expect(drupal.auth).toMatchObject({ + ...auth, + }) + }) + }) + + describe("debug", () => { + test("defaults to `false`", () => { + const consoleSpy = jest.spyOn(console, "debug").mockImplementation(() => { + // + }) + + new NextDrupalFetch(BASE_URL) + + expect(consoleSpy).toBeCalledTimes(0) + }) + + test("turns on debug mode", () => { + const consoleSpy = jest.spyOn(console, "debug").mockImplementation(() => { + // + }) + + new NextDrupalFetch(BASE_URL, { debug: true }) + + expect(consoleSpy).toBeCalledTimes(1) + }) + }) + + describe("fetcher", () => { + test("defaults to `undefined`", () => { + const drupal = new NextDrupalFetch(BASE_URL) + expect(drupal.fetcher).toBe(undefined) + }) + + test("sets up a custom fetcher", () => { + const customFetcher: NextDrupalFetch["fetcher"] = async () => { + // + } + const drupal = new NextDrupalFetch(BASE_URL, { + fetcher: customFetcher, + }) + expect(drupal.fetcher).toBe(customFetcher) + }) + }) + + describe("headers", () => { + test("defaults to `Content-Type`/`Accept`", () => { + const drupal = new NextDrupalFetch(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 NextDrupalFetch(BASE_URL, { + headers: customHeaders, + }) + + expect(Object.fromEntries(drupal.headers.entries())).toMatchObject( + expectedHeaders + ) + }) + }) + + describe("logger", () => { + test("defaults to `console`-based `Logger`", () => { + const drupal = new NextDrupalFetch(BASE_URL) + expect(drupal.logger).toBe(defaultLogger) + }) + + test("sets up a custom logger", () => { + const customLogger: Logger = { + log: () => { + // + }, + debug: () => { + // + }, + warn: () => { + // + }, + error: () => { + // + }, + } + + const drupal = new NextDrupalFetch(BASE_URL, { + logger: customLogger, + }) + expect(drupal.logger).toBe(customLogger) + }) + }) + + describe("withAuth", () => { + test("defaults to `false`", () => { + const drupal = new NextDrupalFetch(BASE_URL) + expect(drupal.withAuth).toBe(false) + }) + + test("can be set to `true`", () => { + const drupal = new NextDrupalFetch(BASE_URL, { + withAuth: true, + }) + expect(drupal.withAuth).toBe(true) + }) + }) +}) diff --git a/packages/next-drupal/tests/NextDrupalFetch/fetch-methods.test.ts b/packages/next-drupal/tests/NextDrupalFetch/fetch-methods.test.ts new file mode 100644 index 00000000..798434f5 --- /dev/null +++ b/packages/next-drupal/tests/NextDrupalFetch/fetch-methods.test.ts @@ -0,0 +1,556 @@ +import { afterEach, describe, expect, jest, test } from "@jest/globals" +import { NextDrupalFetch } from "../../src" +import { + BASE_URL, + mockLogger, + mocks, + spyOnFetch, + spyOnFetchOnce, +} from "../utils" +import type { + AccessToken, + NextDrupalAuth, + NextDrupalFetchOptions, +} 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 NextDrupalFetch(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, 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 NextDrupalFetch(BASE_URL, { + fetcher: customFetch, + debug: true, + logger, + }) + const mockUrl = "https://example.com/mock-url" + const mockInit = { + priority: "high", + } + + 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("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 NextDrupalFetch(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: NextDrupalFetchOptions["fetcher"] = jest.fn() + const drupal = new NextDrupalFetch(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: NextDrupalFetchOptions["fetcher"] = jest.fn() + const drupal = new NextDrupalFetch(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: NextDrupalFetchOptions["fetcher"] = jest.fn() + const drupal = new NextDrupalFetch(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 NextDrupalFetch(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 NextDrupalFetch(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 NextDrupalFetch(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 NextDrupalFetch(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 NextDrupalFetch(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 NextDrupalFetch(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 NextDrupalFetch(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 NextDrupalFetch(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 NextDrupalFetch(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 NextDrupalFetch(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 NextDrupalFetch(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 NextDrupalFetch(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 NextDrupalFetch(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 NextDrupalFetch(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 NextDrupalFetch(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 NextDrupalFetch(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 NextDrupalFetch(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 NextDrupalFetch(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 NextDrupalFetch(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/NextDrupalFetch/getters-setters.test.ts b/packages/next-drupal/tests/NextDrupalFetch/getters-setters.test.ts new file mode 100644 index 00000000..022206e6 --- /dev/null +++ b/packages/next-drupal/tests/NextDrupalFetch/getters-setters.test.ts @@ -0,0 +1,264 @@ +import { afterEach, describe, expect, jest, test } from "@jest/globals" +import { NextDrupalFetch } from "../../src" +import { BASE_URL, mocks } from "../utils" +import type { + AccessToken, + NextDrupalAuthAccessToken, + NextDrupalAuthUsernamePassword, + NextDrupalFetchOptions, +} from "../../src" + +afterEach(() => { + jest.restoreAllMocks() +}) + +describe("apiPrefix", () => { + test("get apiPrefix", () => { + const drupal = new NextDrupalFetch(BASE_URL) + expect(drupal.apiPrefix).toBe("/jsonapi") + }) + test("set apiPrefix", () => { + const drupal = new NextDrupalFetch(BASE_URL) + drupal.apiPrefix = "/api" + expect(drupal.apiPrefix).toBe("/api") + }) + test('set apiPrefix and prefixes with "/"', () => { + const drupal = new NextDrupalFetch(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 NextDrupalFetch(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 NextDrupalFetch(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 NextDrupalFetch(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 NextDrupalFetch(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 NextDrupalFetch(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 NextDrupalFetch(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 NextDrupalFetch(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 NextDrupalFetch(BASE_URL) + drupal.auth = basicAuth + expect(drupal.auth).toMatchObject({ ...basicAuth }) + }) + + test("sets Access Token", () => { + const accessToken = { + ...mocks.auth.accessToken, + } + const drupal = new NextDrupalFetch(BASE_URL) + drupal.auth = accessToken + expect(drupal.auth).toMatchObject({ ...accessToken }) + }) + + test("sets Client ID/Secret", () => { + const clientIdSecret = { + ...mocks.auth.clientIdSecret, + } + const drupal = new NextDrupalFetch(BASE_URL) + drupal.auth = clientIdSecret + expect(drupal.auth).toMatchObject({ ...clientIdSecret }) + }) + + test("sets auth function", () => { + const authFunction = mocks.auth.callback + const drupal = new NextDrupalFetch(BASE_URL) + drupal.auth = authFunction + expect(drupal.auth).toBe(authFunction) + }) + + test("sets custom Authorization string", () => { + const authString = `${mocks.auth.customAuthenticationHeader}` + const drupal = new NextDrupalFetch(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 NextDrupalFetch(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 NextDrupalFetch(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 NextDrupalFetchOptions["headers"] + const expectedHeaders = {} + Object.keys(headers).forEach((header) => { + expectedHeaders[header.toLowerCase()] = headers[header] + }) + + test("get headers", () => { + const drupal = new NextDrupalFetch(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 NextDrupalFetch(BASE_URL) + drupal.headers = keyValuePairs + + expect(Object.fromEntries(drupal.headers.entries())).toMatchObject( + expectedHeaders + ) + }) + + test("set headers using object literal", () => { + const drupal = new NextDrupalFetch(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 NextDrupalFetch(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 NextDrupalFetch(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 NextDrupalFetch(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/draft/draft.test.ts b/packages/next-drupal/tests/draft/draft.test.ts index 10499087..3f578ea0 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 slug path", async () => { spyOnFetch({ responseBody: validationPayload }) - await enableDraftMode(request, client) + await enableDraftMode(request, drupal) expect(redirect).toHaveBeenCalledWith(searchParams.get("slug")) }) 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, }) }