diff --git a/packages/next-drupal/package.json b/packages/next-drupal/package.json index 37183d5d..292d63af 100644 --- a/packages/next-drupal/package.json +++ b/packages/next-drupal/package.json @@ -3,10 +3,80 @@ "description": "Helpers for Next.js + Drupal.", "version": "1.6.0", "sideEffects": false, - "source": "src/index.ts", + "source": [ + "src/index.ts", + "src/client.ts", + "src/navigation.ts", + "src/preview.ts", + "src/query.ts", + "src/translation.ts", + "src/utils.ts" + ], "type": "module", "main": "dist/index.cjs", "module": "dist/index.modern.js", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.modern.js" + }, + "require": { + "types": "./dist/index.cjs.d.ts", + "default": "./dist/index.cjs" + } + }, + "./client": { + "import": { + "types": "./dist/client.d.ts", + "default": "./dist/client.modern.js" + }, + "require": { + "types": "./dist/client.cjs.d.ts", + "default": "./dist/client.cjs" + } + }, + "./navigation": { + "import": { + "types": "./dist/navigation.d.ts", + "default": "./dist/navigation.modern.js" + }, + "require": { + "types": "./dist/navigation.cjs.d.ts", + "default": "./dist/navigation.cjs" + } + }, + "./preview": { + "import": { + "types": "./dist/preview.d.ts", + "default": "./dist/preview.modern.js" + }, + "require": { + "types": "./dist/preview.cjs.d.ts", + "default": "./dist/preview.cjs" + } + }, + "./query": { + "import": { + "types": "./dist/query.d.ts", + "default": "./dist/query.modern.js" + }, + "require": { + "types": "./dist/query.cjs.d.ts", + "default": "./dist/query.cjs" + } + }, + "./translation": { + "import": { + "types": "./dist/translation.d.ts", + "default": "./dist/translation.modern.js" + }, + "require": { + "types": "./dist/translation.cjs.d.ts", + "default": "./dist/translation.cjs" + } + } + }, "types": "dist/types.d.ts", "license": "MIT", "publishConfig": { @@ -18,6 +88,7 @@ }, "scripts": { "prepare": "microbundle --no-compress --jsx React.createElement --format modern,cjs", + "postprepare": "node postBuild.mjs", "dev": "microbundle watch --no-compress --jsx React.createElement --format modern,cjs", "test": "jest", "prepublishOnly": "yarn prepare" diff --git a/packages/next-drupal/postBuild.mjs b/packages/next-drupal/postBuild.mjs new file mode 100644 index 00000000..0fbc9267 --- /dev/null +++ b/packages/next-drupal/postBuild.mjs @@ -0,0 +1,20 @@ +import { readdir, copyFile } from "node:fs/promises" + +const files = await readdir("./dist") +for (const file of files) { + if (file.endsWith(".modern.js")) { + const base = file.replace(/\.modern\.js$/, "") + + // Make a duplicate of the type definitions. + // + // From the TypeScript docs: + // + // "It’s important to note that the CommonJS entrypoint and the ES module + // entrypoint each needs its own declaration file, even if the contents are + // the same between them." + // + // @see https://www.typescriptlang.org/docs/handbook/esm-node.html#packagejson-exports-imports-and-self-referencing + await copyFile(`./dist/${base}.d.ts`, `./dist/${base}.cjs.d.ts`) + } +} +console.log(`Created unique *.d.ts files for CommonJS build.`) diff --git a/packages/next-drupal/src/client.ts b/packages/next-drupal/src/client.ts index 2c46353a..e4483671 100644 --- a/packages/next-drupal/src/client.ts +++ b/packages/next-drupal/src/client.ts @@ -1,1483 +1,2 @@ -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, - DrupalClientAuthAccessToken, - DrupalClientAuthClientIdSecret, - DrupalClientAuthUsernamePassword, - DrupalClientOptions, - DrupalFile, - DrupalMenuLinkContent, - DrupalTranslatedPath, - DrupalView, - FetchOptions, - JsonApiCreateFileResourceBody, - JsonApiCreateResourceBody, - JsonApiParams, - JsonApiResource, - JsonApiResourceWithPath, - JsonApiResponse, - JsonApiUpdateResourceBody, - JsonApiWithAuthOptions, - JsonApiWithCacheOptions, - JsonApiWithLocaleOptions, - Locale, - PathAlias, - PathPrefix, - PreviewOptions, -} from "./types" - -const DEFAULT_API_PREFIX = "/jsonapi" -const DEFAULT_FRONT_PAGE = "/home" -const DEFAULT_WITH_AUTH = false - -// From simple_oauth. -const DEFAULT_AUTH_URL = "/oauth/token" - -// See https://jsonapi.org/format/#content-negotiation. -const DEFAULT_HEADERS = { - "Content-Type": "application/vnd.api+json", - Accept: "application/vnd.api+json", -} - -function isBasicAuth( - auth: DrupalClientOptions["auth"] -): auth is DrupalClientAuthUsernamePassword { - return ( - (auth as DrupalClientAuthUsernamePassword)?.username !== undefined || - (auth as DrupalClientAuthUsernamePassword)?.password !== undefined - ) -} - -function isAccessTokenAuth( - auth: DrupalClientOptions["auth"] -): auth is DrupalClientAuthAccessToken { - return (auth as DrupalClientAuthAccessToken)?.access_token !== undefined -} - -function isClientIdSecretAuth( - auth: DrupalClient["auth"] -): auth is DrupalClientAuthClientIdSecret { - return ( - (auth as DrupalClientAuthClientIdSecret)?.clientId !== undefined || - (auth as DrupalClientAuthClientIdSecret)?.clientSecret !== undefined - ) -} - -export class DrupalClient { - baseUrl: BaseUrl - - debug: DrupalClientOptions["debug"] - - frontPage: DrupalClientOptions["frontPage"] - - 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"] - - private previewSecret?: DrupalClientOptions["previewSecret"] - - private forceIframeSameSiteCookie?: DrupalClientOptions["forceIframeSameSiteCookie"] - - /** - * 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, - previewSecret, - accessToken, - forceIframeSameSiteCookie = false, - throwJsonApiErrors = true, - } = options - - this.baseUrl = baseUrl - this.apiPrefix = apiPrefix - this.serializer = serializer - this.frontPage = frontPage - this.debug = debug - this.useDefaultResourceTypeEntry = useDefaultResourceTypeEntry - this.fetcher = fetcher - this.auth = auth - this.headers = headers - this.logger = logger - this.withAuth = withAuth - this.previewSecret = previewSecret - this.cache = cache - this.accessToken = accessToken - this.forceIframeSameSiteCookie = forceIframeSameSiteCookie - 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") { - if (isBasicAuth(auth)) { - if (!auth.username || !auth.password) { - throw new Error( - `'username' and 'password' are required for auth. See https://next-drupal.org/docs/client/auth` - ) - } - } else if (isAccessTokenAuth(auth)) { - if (!auth.access_token || !auth.token_type) { - throw new Error( - `'access_token' and 'token_type' are required for auth. See https://next-drupal.org/docs/client/auth` - ) - } - } else if (!auth.clientId || !auth.clientSecret) { - throw new Error( - `'clientId' and 'clientSecret' are required for auth. See https://next-drupal.org/docs/client/auth` - ) - } - - auth = { - url: DEFAULT_AUTH_URL, - ...auth, - } - } - - 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 - } - - /* eslint-disable @typescript-eslint/no-explicit-any */ - async fetch(input: RequestInfo, init?: FetchOptions): Promise { - init = { - ...init, - credentials: "include", - headers: { - ...this._headers, - ...init?.headers, - }, - } - - // Using the auth set on the client. - // TODO: Abstract this to a re-usable. - if (init?.withAuth) { - this._debug(`Using authenticated request.`) - - if (init.withAuth === true) { - if (typeof this._auth === "undefined") { - throw new Error( - "auth is not configured. See https://next-drupal.org/docs/client/auth" - ) - } - - // By default, if withAuth is set to true, we use the auth configured - // in the client constructor. - if (typeof this._auth === "function") { - this._debug(`Using custom auth callback.`) - - init["headers"]["Authorization"] = this._auth() - } else if (typeof this._auth === "string") { - this._debug(`Using custom authorization header.`) - - init["headers"]["Authorization"] = this._auth - } else if (typeof this._auth === "object") { - this._debug(`Using custom auth credentials.`) - - if (isBasicAuth(this._auth)) { - const basic = Buffer.from( - `${this._auth.username}:${this._auth.password}` - ).toString("base64") - - init["headers"]["Authorization"] = `Basic ${basic}` - } else if (isClientIdSecretAuth(this._auth)) { - // Use the built-in client_credentials grant. - this._debug(`Using default auth (client_credentials).`) - - // Fetch an access token and add it to the request. - // Access token can be fetched from cache or using a custom auth method. - const token = await this.getAccessToken(this._auth) - if (token) { - init["headers"]["Authorization"] = `Bearer ${token.access_token}` - } - } else if (isAccessTokenAuth(this._auth)) { - init["headers"][ - "Authorization" - ] = `${this._auth.token_type} ${this._auth.access_token}` - } - } - } else if (typeof init.withAuth === "string") { - this._debug(`Using custom authorization header.`) - - init["headers"]["Authorization"] = init.withAuth - } else if (typeof init.withAuth === "function") { - this._debug(`Using custom authorization callback.`) - - init["headers"]["Authorization"] = init.withAuth() - } else if (isBasicAuth(init.withAuth)) { - this._debug(`Using basic authorization header`) - - const basic = Buffer.from( - `${init.withAuth.username}:${init.withAuth.password}` - ).toString("base64") - - init["headers"]["Authorization"] = `Basic ${basic}` - } else if (isClientIdSecretAuth(init.withAuth)) { - // Fetch an access token and add it to the request. - // Access token can be fetched from cache or using a custom auth method. - const token = await this.getAccessToken(init.withAuth) - if (token) { - init["headers"]["Authorization"] = `Bearer ${token.access_token}` - } - } else if (isAccessTokenAuth(init.withAuth)) { - init["headers"][ - "Authorization" - ] = `${init.withAuth.token_type} ${init.withAuth.access_token}` - } - } - - if (this.fetcher) { - this._debug(`Using custom fetcher.`) - - return await this.fetcher(input, init) - } - - this._debug(`Using default fetch (polyfilled by Next.js).`) - - return await fetch(input, init) - } - - async createResource( - type: string, - body: JsonApiCreateResourceBody, - options?: JsonApiWithLocaleOptions & JsonApiWithAuthOptions - ): Promise { - options = { - deserialize: true, - withAuth: true, - ...options, - } - - const apiPath = await this.getEntryForResourceType( - type, - options?.locale !== options?.defaultLocale ? options.locale : undefined - ) - - const url = this.buildUrl(apiPath, options?.params) - - this._debug(`Creating resource of type ${type}.`) - this._debug(url.toString()) - - // Add type to body. - body.data.type = type - - const response = await this.fetch(url.toString(), { - method: "POST", - body: JSON.stringify(body), - withAuth: options.withAuth, - }) - - if (!response?.ok) { - await this.handleJsonApiErrors(response) - } - - const json = await response.json() - - return options.deserialize ? this.deserialize(json) : json - } - - async createFileResource( - type: string, - body: JsonApiCreateFileResourceBody, - options?: JsonApiWithLocaleOptions & JsonApiWithAuthOptions - ): 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}.`) - this._debug(url.toString()) - - 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, - }) - - if (!response?.ok) { - await this.handleJsonApiErrors(response) - } - - const json = await response.json() - - return options.deserialize ? this.deserialize(json) : json - } - - async updateResource( - type: string, - uuid: string, - body: JsonApiUpdateResourceBody, - options?: JsonApiWithLocaleOptions & JsonApiWithAuthOptions - ): Promise { - options = { - deserialize: true, - withAuth: true, - ...options, - } - - const apiPath = await this.getEntryForResourceType( - type, - options?.locale !== options?.defaultLocale ? options.locale : undefined - ) - - const url = this.buildUrl(`${apiPath}/${uuid}`, options?.params) - - this._debug(`Updating resource of type ${type} with id ${uuid}.`) - this._debug(url.toString()) - - // 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, - }) - - if (!response?.ok) { - await this.handleJsonApiErrors(response) - } - - const json = await response.json() - - return options.deserialize ? this.deserialize(json) : json - } - - async deleteResource( - type: string, - uuid: string, - options?: JsonApiWithLocaleOptions & JsonApiWithAuthOptions - ): Promise { - options = { - withAuth: true, - params: {}, - ...options, - } - - const apiPath = await this.getEntryForResourceType( - type, - options?.locale !== options?.defaultLocale ? options.locale : undefined - ) - - const url = this.buildUrl(`${apiPath}/${uuid}`, options?.params) - - this._debug(`Deleting resource of type ${type} with id ${uuid}.`) - this._debug(url.toString()) - - const response = await this.fetch(url.toString(), { - method: "DELETE", - withAuth: options.withAuth, - }) - - if (!response?.ok) { - await this.handleJsonApiErrors(response) - } - - return response.status === 204 - } - - async getResource( - type: string, - uuid: string, - options?: JsonApiWithLocaleOptions & - JsonApiWithAuthOptions & - JsonApiWithCacheOptions - ): Promise { - options = { - deserialize: true, - withAuth: this.withAuth, - withCache: false, - params: {}, - ...options, - } - - 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}.`) - this._debug(url.toString()) - - const response = await this.fetch(url.toString(), { - withAuth: options.withAuth, - }) - - if (!response?.ok) { - await this.handleJsonApiErrors(response) - } - - const json = await response.json() - - 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 - } & JsonApiWithLocaleOptions & - JsonApiWithAuthOptions - ): 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. - // @shadcn, note to self: - // 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. - 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 - } & JsonApiWithLocaleOptions & - JsonApiWithAuthOptions - ): 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 === "/" ? 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", - }) - - 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 - } & JsonApiWithLocaleOptions & - JsonApiWithAuthOptions - ): 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}`) - this._debug(url.toString()) - - const response = await this.fetch(url.toString(), { - withAuth: options.withAuth, - }) - - if (!response?.ok) { - await this.handleJsonApiErrors(response) - } - - const json = await response.json() - - return options.deserialize ? this.deserialize(json) : json - } - - async getResourceCollectionFromContext( - type: string, - context: GetStaticPropsContext, - options?: { - deserialize?: boolean - } & JsonApiWithLocaleOptions & - JsonApiWithAuthOptions - ): 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 - } & JsonApiWithAuthOptions - ): 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?: JsonApiWithAuthOptions - ): Promise { - options = { - withAuth: this.withAuth, - ...options, - } - - const url = this.buildUrl("/router/translate-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 - } & JsonApiWithAuthOptions - ): 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 { - 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, "") - - if (!new RegExp(pattern, "i").test(path)) { - return `${this.baseUrl}/${locale}${path}` - } - } - - return href - } - - async preview( - request?: NextApiRequest, - response?: NextApiResponse, - options?: PreviewOptions - ) { - const { slug, resourceVersion, plugin } = request.query - - try { - // Always clear preview data to handle different scopes. - response.clearPreviewData() - - // Validate the preview url. - const validateUrl = this.buildUrl("/next/preview-url") - const result = await this.fetch(validateUrl.toString(), { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(request.query), - }) - - if (!result.ok) { - response.statusCode = result.status - - return response.json(await result.json()) - } - - const validationPayload = await result.json() - - response.setPreviewData({ - resourceVersion, - plugin, - ...validationPayload, - }) - - // 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. - if (this.forceIframeSameSiteCookie) { - const previous = response.getHeader("Set-Cookie") as string[] - previous.forEach((cookie, index) => { - previous[index] = cookie.replace( - "SameSite=Lax", - "SameSite=None;Secure" - ) - }) - response.setHeader(`Set-Cookie`, previous) - } - - // We can safely redirect to the slug since this has been validated on the server. - response.writeHead(307, { Location: slug }) - - return response.end() - } catch (error) { - return response.status(422).end() - } - } - - async getMenu( - name: string, - options?: JsonApiWithLocaleOptions & - JsonApiWithAuthOptions & - JsonApiWithCacheOptions - ): Promise<{ - items: T[] - tree: T[] - }> { - options = { - withAuth: this.withAuth, - deserialize: true, - params: {}, - withCache: false, - ...options, - } - - 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}.`) - this._debug(url.toString()) - - const response = await this.fetch(url.toString(), { - withAuth: options.withAuth, - }) - - if (!response?.ok) { - await this.handleJsonApiErrors(response) - } - - const data = await response.json() - - const items = options.deserialize ? this.deserialize(data) : data - - const { items: tree } = this.buildMenuTree(items) - - const menu = { - items, - tree, - } - - 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?: JsonApiWithLocaleOptions & JsonApiWithAuthOptions - ): 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 - ) - - const response = await this.fetch(url.toString(), { - withAuth: options.withAuth, - }) - - if (!response?.ok) { - await this.handleJsonApiErrors(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?: JsonApiWithLocaleOptions & JsonApiWithAuthOptions - ): 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 - ) - - const response = await this.fetch(url.toString(), { - withAuth: options.withAuth, - }) - - if (!response?.ok) { - await this.handleJsonApiErrors(response) - } - - const json = await response.json() - - return options.deserialize ? this.deserialize(json) : json - } - - async getSearchIndexFromContext( - name: string, - context: GetStaticPropsContext, - options?: JsonApiWithLocaleOptions & JsonApiWithAuthOptions - ): 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 - } - - if (!opts?.clientId || !opts?.clientSecret) { - if (typeof this._auth === "undefined") { - throw new Error( - "auth is not configured. See https://next-drupal.org/docs/client/auth" - ) - } - } - - if ( - !isClientIdSecretAuth(this._auth) || - (opts && !isClientIdSecretAuth(opts)) - ) { - throw new Error( - `'clientId' and 'clientSecret' required. See https://next-drupal.org/docs/client/auth` - ) - } - - const clientId = opts?.clientId || this._auth.clientId - const clientSecret = opts?.clientSecret || this._auth.clientSecret - const url = this.buildUrl(opts?.url || this._auth.url || DEFAULT_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.`) - - const basic = Buffer.from(`${clientId}:${clientSecret}`).toString("base64") - - 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: `Basic ${basic}`, - Accept: "application/json", - "Content-Type": "application/x-www-form-urlencoded", - }, - body, - }) - - if (!response?.ok) { - await this.handleJsonApiErrors(response) - } - - const result: AccessToken = await response.json() - - this._debug(result) - - 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 - } - - private _debug(message) { - !!this.debug && this.logger.debug(message) - } - - // Error handling. - // If throwErrors is enable, 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 handleJsonApiErrors(response: Response) { - if (!response?.ok) { - const errors = await this.getErrorsFromResponse(response) - throw new JsonApiErrors(errors, response.status) - } - } - - private getAuthFromContextAndOptions( - context: GetStaticPropsContext, - options: JsonApiWithAuthOptions - ) { - // 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 - } -} +export { DrupalClient } from "./client/drupal-client" +export { JsonApiErrors } from "./client/jsonapi-errors" diff --git a/packages/next-drupal/src/logger.ts b/packages/next-drupal/src/client/console-logger.ts similarity index 80% rename from packages/next-drupal/src/logger.ts rename to packages/next-drupal/src/client/console-logger.ts index e1b207e8..4af69c25 100644 --- a/packages/next-drupal/src/logger.ts +++ b/packages/next-drupal/src/client/console-logger.ts @@ -1,7 +1,7 @@ -import type { Logger } from "./types" +import type { Logger } from "../types" // Default logger. Uses console. -export const logger: Logger = { +export const consoleLogger: Logger = { log(message) { console.log(`[next-drupal][log]:`, message) }, diff --git a/packages/next-drupal/src/client/drupal-client.ts b/packages/next-drupal/src/client/drupal-client.ts new file mode 100644 index 00000000..3910027f --- /dev/null +++ b/packages/next-drupal/src/client/drupal-client.ts @@ -0,0 +1,1483 @@ +import Jsona from "jsona" +import { stringify } from "qs" +import { JsonApiErrors } from "./jsonapi-errors" +import { consoleLogger as defaultLogger } from "./console-logger" +import type { + GetStaticPathsContext, + GetStaticPathsResult, + GetStaticPropsContext, + NextApiRequest, + NextApiResponse, +} from "next" +import type { + AccessToken, + BaseUrl, + DrupalClientAuthAccessToken, + DrupalClientAuthClientIdSecret, + DrupalClientAuthUsernamePassword, + DrupalClientOptions, + DrupalFile, + DrupalMenuLinkContent, + DrupalTranslatedPath, + DrupalView, + FetchOptions, + JsonApiCreateFileResourceBody, + JsonApiCreateResourceBody, + JsonApiParams, + JsonApiResource, + JsonApiResourceWithPath, + JsonApiResponse, + JsonApiUpdateResourceBody, + JsonApiWithAuthOptions, + JsonApiWithCacheOptions, + JsonApiWithLocaleOptions, + Locale, + PathAlias, + PathPrefix, + PreviewOptions, +} from "../types" + +const DEFAULT_API_PREFIX = "/jsonapi" +const DEFAULT_FRONT_PAGE = "/home" +const DEFAULT_WITH_AUTH = false + +// From simple_oauth. +const DEFAULT_AUTH_URL = "/oauth/token" + +// See https://jsonapi.org/format/#content-negotiation. +const DEFAULT_HEADERS = { + "Content-Type": "application/vnd.api+json", + Accept: "application/vnd.api+json", +} + +function isBasicAuth( + auth: DrupalClientOptions["auth"] +): auth is DrupalClientAuthUsernamePassword { + return ( + (auth as DrupalClientAuthUsernamePassword)?.username !== undefined || + (auth as DrupalClientAuthUsernamePassword)?.password !== undefined + ) +} + +function isAccessTokenAuth( + auth: DrupalClientOptions["auth"] +): auth is DrupalClientAuthAccessToken { + return (auth as DrupalClientAuthAccessToken)?.access_token !== undefined +} + +function isClientIdSecretAuth( + auth: DrupalClient["auth"] +): auth is DrupalClientAuthClientIdSecret { + return ( + (auth as DrupalClientAuthClientIdSecret)?.clientId !== undefined || + (auth as DrupalClientAuthClientIdSecret)?.clientSecret !== undefined + ) +} + +export class DrupalClient { + baseUrl: BaseUrl + + debug: DrupalClientOptions["debug"] + + frontPage: DrupalClientOptions["frontPage"] + + 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"] + + private previewSecret?: DrupalClientOptions["previewSecret"] + + private forceIframeSameSiteCookie?: DrupalClientOptions["forceIframeSameSiteCookie"] + + /** + * 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, + previewSecret, + accessToken, + forceIframeSameSiteCookie = false, + throwJsonApiErrors = true, + } = options + + this.baseUrl = baseUrl + this.apiPrefix = apiPrefix + this.serializer = serializer + this.frontPage = frontPage + this.debug = debug + this.useDefaultResourceTypeEntry = useDefaultResourceTypeEntry + this.fetcher = fetcher + this.auth = auth + this.headers = headers + this.logger = logger + this.withAuth = withAuth + this.previewSecret = previewSecret + this.cache = cache + this.accessToken = accessToken + this.forceIframeSameSiteCookie = forceIframeSameSiteCookie + 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") { + if (isBasicAuth(auth)) { + if (!auth.username || !auth.password) { + throw new Error( + `'username' and 'password' are required for auth. See https://next-drupal.org/docs/client/auth` + ) + } + } else if (isAccessTokenAuth(auth)) { + if (!auth.access_token || !auth.token_type) { + throw new Error( + `'access_token' and 'token_type' are required for auth. See https://next-drupal.org/docs/client/auth` + ) + } + } else if (!auth.clientId || !auth.clientSecret) { + throw new Error( + `'clientId' and 'clientSecret' are required for auth. See https://next-drupal.org/docs/client/auth` + ) + } + + auth = { + url: DEFAULT_AUTH_URL, + ...auth, + } + } + + 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 + } + + /* eslint-disable @typescript-eslint/no-explicit-any */ + async fetch(input: RequestInfo, init?: FetchOptions): Promise { + init = { + ...init, + credentials: "include", + headers: { + ...this._headers, + ...init?.headers, + }, + } + + // Using the auth set on the client. + // TODO: Abstract this to a re-usable. + if (init?.withAuth) { + this._debug(`Using authenticated request.`) + + if (init.withAuth === true) { + if (typeof this._auth === "undefined") { + throw new Error( + "auth is not configured. See https://next-drupal.org/docs/client/auth" + ) + } + + // By default, if withAuth is set to true, we use the auth configured + // in the client constructor. + if (typeof this._auth === "function") { + this._debug(`Using custom auth callback.`) + + init["headers"]["Authorization"] = this._auth() + } else if (typeof this._auth === "string") { + this._debug(`Using custom authorization header.`) + + init["headers"]["Authorization"] = this._auth + } else if (typeof this._auth === "object") { + this._debug(`Using custom auth credentials.`) + + if (isBasicAuth(this._auth)) { + const basic = Buffer.from( + `${this._auth.username}:${this._auth.password}` + ).toString("base64") + + init["headers"]["Authorization"] = `Basic ${basic}` + } else if (isClientIdSecretAuth(this._auth)) { + // Use the built-in client_credentials grant. + this._debug(`Using default auth (client_credentials).`) + + // Fetch an access token and add it to the request. + // Access token can be fetched from cache or using a custom auth method. + const token = await this.getAccessToken(this._auth) + if (token) { + init["headers"]["Authorization"] = `Bearer ${token.access_token}` + } + } else if (isAccessTokenAuth(this._auth)) { + init["headers"][ + "Authorization" + ] = `${this._auth.token_type} ${this._auth.access_token}` + } + } + } else if (typeof init.withAuth === "string") { + this._debug(`Using custom authorization header.`) + + init["headers"]["Authorization"] = init.withAuth + } else if (typeof init.withAuth === "function") { + this._debug(`Using custom authorization callback.`) + + init["headers"]["Authorization"] = init.withAuth() + } else if (isBasicAuth(init.withAuth)) { + this._debug(`Using basic authorization header`) + + const basic = Buffer.from( + `${init.withAuth.username}:${init.withAuth.password}` + ).toString("base64") + + init["headers"]["Authorization"] = `Basic ${basic}` + } else if (isClientIdSecretAuth(init.withAuth)) { + // Fetch an access token and add it to the request. + // Access token can be fetched from cache or using a custom auth method. + const token = await this.getAccessToken(init.withAuth) + if (token) { + init["headers"]["Authorization"] = `Bearer ${token.access_token}` + } + } else if (isAccessTokenAuth(init.withAuth)) { + init["headers"][ + "Authorization" + ] = `${init.withAuth.token_type} ${init.withAuth.access_token}` + } + } + + if (this.fetcher) { + this._debug(`Using custom fetcher.`) + + return await this.fetcher(input, init) + } + + this._debug(`Using default fetch (polyfilled by Next.js).`) + + return await fetch(input, init) + } + + async createResource( + type: string, + body: JsonApiCreateResourceBody, + options?: JsonApiWithLocaleOptions & JsonApiWithAuthOptions + ): Promise { + options = { + deserialize: true, + withAuth: true, + ...options, + } + + const apiPath = await this.getEntryForResourceType( + type, + options?.locale !== options?.defaultLocale ? options.locale : undefined + ) + + const url = this.buildUrl(apiPath, options?.params) + + this._debug(`Creating resource of type ${type}.`) + this._debug(url.toString()) + + // Add type to body. + body.data.type = type + + const response = await this.fetch(url.toString(), { + method: "POST", + body: JSON.stringify(body), + withAuth: options.withAuth, + }) + + if (!response?.ok) { + await this.handleJsonApiErrors(response) + } + + const json = await response.json() + + return options.deserialize ? this.deserialize(json) : json + } + + async createFileResource( + type: string, + body: JsonApiCreateFileResourceBody, + options?: JsonApiWithLocaleOptions & JsonApiWithAuthOptions + ): 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}.`) + this._debug(url.toString()) + + 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, + }) + + if (!response?.ok) { + await this.handleJsonApiErrors(response) + } + + const json = await response.json() + + return options.deserialize ? this.deserialize(json) : json + } + + async updateResource( + type: string, + uuid: string, + body: JsonApiUpdateResourceBody, + options?: JsonApiWithLocaleOptions & JsonApiWithAuthOptions + ): Promise { + options = { + deserialize: true, + withAuth: true, + ...options, + } + + const apiPath = await this.getEntryForResourceType( + type, + options?.locale !== options?.defaultLocale ? options.locale : undefined + ) + + const url = this.buildUrl(`${apiPath}/${uuid}`, options?.params) + + this._debug(`Updating resource of type ${type} with id ${uuid}.`) + this._debug(url.toString()) + + // 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, + }) + + if (!response?.ok) { + await this.handleJsonApiErrors(response) + } + + const json = await response.json() + + return options.deserialize ? this.deserialize(json) : json + } + + async deleteResource( + type: string, + uuid: string, + options?: JsonApiWithLocaleOptions & JsonApiWithAuthOptions + ): Promise { + options = { + withAuth: true, + params: {}, + ...options, + } + + const apiPath = await this.getEntryForResourceType( + type, + options?.locale !== options?.defaultLocale ? options.locale : undefined + ) + + const url = this.buildUrl(`${apiPath}/${uuid}`, options?.params) + + this._debug(`Deleting resource of type ${type} with id ${uuid}.`) + this._debug(url.toString()) + + const response = await this.fetch(url.toString(), { + method: "DELETE", + withAuth: options.withAuth, + }) + + if (!response?.ok) { + await this.handleJsonApiErrors(response) + } + + return response.status === 204 + } + + async getResource( + type: string, + uuid: string, + options?: JsonApiWithLocaleOptions & + JsonApiWithAuthOptions & + JsonApiWithCacheOptions + ): Promise { + options = { + deserialize: true, + withAuth: this.withAuth, + withCache: false, + params: {}, + ...options, + } + + 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}.`) + this._debug(url.toString()) + + const response = await this.fetch(url.toString(), { + withAuth: options.withAuth, + }) + + if (!response?.ok) { + await this.handleJsonApiErrors(response) + } + + const json = await response.json() + + 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 + } & JsonApiWithLocaleOptions & + JsonApiWithAuthOptions + ): 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. + // @shadcn, note to self: + // 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. + 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 + } & JsonApiWithLocaleOptions & + JsonApiWithAuthOptions + ): 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 === "/" ? 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", + }) + + 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 + } & JsonApiWithLocaleOptions & + JsonApiWithAuthOptions + ): 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}`) + this._debug(url.toString()) + + const response = await this.fetch(url.toString(), { + withAuth: options.withAuth, + }) + + if (!response?.ok) { + await this.handleJsonApiErrors(response) + } + + const json = await response.json() + + return options.deserialize ? this.deserialize(json) : json + } + + async getResourceCollectionFromContext( + type: string, + context: GetStaticPropsContext, + options?: { + deserialize?: boolean + } & JsonApiWithLocaleOptions & + JsonApiWithAuthOptions + ): 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 + } & JsonApiWithAuthOptions + ): 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?: JsonApiWithAuthOptions + ): Promise { + options = { + withAuth: this.withAuth, + ...options, + } + + const url = this.buildUrl("/router/translate-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 + } & JsonApiWithAuthOptions + ): 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 { + 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, "") + + if (!new RegExp(pattern, "i").test(path)) { + return `${this.baseUrl}/${locale}${path}` + } + } + + return href + } + + async preview( + request?: NextApiRequest, + response?: NextApiResponse, + options?: PreviewOptions + ) { + const { slug, resourceVersion, plugin } = request.query + + try { + // Always clear preview data to handle different scopes. + response.clearPreviewData() + + // Validate the preview url. + const validateUrl = this.buildUrl("/next/preview-url") + const result = await this.fetch(validateUrl.toString(), { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(request.query), + }) + + if (!result.ok) { + response.statusCode = result.status + + return response.json(await result.json()) + } + + const validationPayload = await result.json() + + response.setPreviewData({ + resourceVersion, + plugin, + ...validationPayload, + }) + + // 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. + if (this.forceIframeSameSiteCookie) { + const previous = response.getHeader("Set-Cookie") as string[] + previous.forEach((cookie, index) => { + previous[index] = cookie.replace( + "SameSite=Lax", + "SameSite=None;Secure" + ) + }) + response.setHeader(`Set-Cookie`, previous) + } + + // We can safely redirect to the slug since this has been validated on the server. + response.writeHead(307, { Location: slug }) + + return response.end() + } catch (error) { + return response.status(422).end() + } + } + + async getMenu( + name: string, + options?: JsonApiWithLocaleOptions & + JsonApiWithAuthOptions & + JsonApiWithCacheOptions + ): Promise<{ + items: T[] + tree: T[] + }> { + options = { + withAuth: this.withAuth, + deserialize: true, + params: {}, + withCache: false, + ...options, + } + + 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}.`) + this._debug(url.toString()) + + const response = await this.fetch(url.toString(), { + withAuth: options.withAuth, + }) + + if (!response?.ok) { + await this.handleJsonApiErrors(response) + } + + const data = await response.json() + + const items = options.deserialize ? this.deserialize(data) : data + + const { items: tree } = this.buildMenuTree(items) + + const menu = { + items, + tree, + } + + 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?: JsonApiWithLocaleOptions & JsonApiWithAuthOptions + ): 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 + ) + + const response = await this.fetch(url.toString(), { + withAuth: options.withAuth, + }) + + if (!response?.ok) { + await this.handleJsonApiErrors(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?: JsonApiWithLocaleOptions & JsonApiWithAuthOptions + ): 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 + ) + + const response = await this.fetch(url.toString(), { + withAuth: options.withAuth, + }) + + if (!response?.ok) { + await this.handleJsonApiErrors(response) + } + + const json = await response.json() + + return options.deserialize ? this.deserialize(json) : json + } + + async getSearchIndexFromContext( + name: string, + context: GetStaticPropsContext, + options?: JsonApiWithLocaleOptions & JsonApiWithAuthOptions + ): 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 + } + + if (!opts?.clientId || !opts?.clientSecret) { + if (typeof this._auth === "undefined") { + throw new Error( + "auth is not configured. See https://next-drupal.org/docs/client/auth" + ) + } + } + + if ( + !isClientIdSecretAuth(this._auth) || + (opts && !isClientIdSecretAuth(opts)) + ) { + throw new Error( + `'clientId' and 'clientSecret' required. See https://next-drupal.org/docs/client/auth` + ) + } + + const clientId = opts?.clientId || this._auth.clientId + const clientSecret = opts?.clientSecret || this._auth.clientSecret + const url = this.buildUrl(opts?.url || this._auth.url || DEFAULT_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.`) + + const basic = Buffer.from(`${clientId}:${clientSecret}`).toString("base64") + + 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: `Basic ${basic}`, + Accept: "application/json", + "Content-Type": "application/x-www-form-urlencoded", + }, + body, + }) + + if (!response?.ok) { + await this.handleJsonApiErrors(response) + } + + const result: AccessToken = await response.json() + + this._debug(result) + + 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 + } + + private _debug(message) { + !!this.debug && this.logger.debug(message) + } + + // Error handling. + // If throwErrors is enable, 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 handleJsonApiErrors(response: Response) { + if (!response?.ok) { + const errors = await this.getErrorsFromResponse(response) + throw new JsonApiErrors(errors, response.status) + } + } + + private getAuthFromContextAndOptions( + context: GetStaticPropsContext, + options: JsonApiWithAuthOptions + ) { + // 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/jsonapi-errors.ts b/packages/next-drupal/src/client/jsonapi-errors.ts similarity index 92% rename from packages/next-drupal/src/jsonapi-errors.ts rename to packages/next-drupal/src/client/jsonapi-errors.ts index d26f6f9c..6dc6e900 100644 --- a/packages/next-drupal/src/jsonapi-errors.ts +++ b/packages/next-drupal/src/client/jsonapi-errors.ts @@ -1,4 +1,4 @@ -import type { JsonApiError } from "./types" +import type { JsonApiError } from "../types" export class JsonApiErrors extends Error { errors: JsonApiError[] | string diff --git a/packages/next-drupal/src/index.ts b/packages/next-drupal/src/index.ts index bc107a47..1334f470 100644 --- a/packages/next-drupal/src/index.ts +++ b/packages/next-drupal/src/index.ts @@ -1,22 +1,29 @@ -export * from "./get-access-token" -export * from "./get-menu" -export * from "./get-paths" -export * from "./get-resource-collection" -export * from "./preview" -export * from "./get-resource-type" -export * from "./get-resource" -export * from "./get-search-index" -export * from "./get-view" -export * from "./types" -export * from "./use-menu" -export * from "./translate-path" +export { DrupalClient, JsonApiErrors } from "./client" +export { useMenu } from "./navigation" +export { DrupalPreview, getResourcePreviewUrl, PreviewHandler } from "./preview" +export { + getAccessToken, + getMenu, + getPathsFromContext, + getResource, + getResourceByPath, + getResourceFromContext, + getResourceCollection, + getResourceCollectionFromContext, + getResourceTypeFromContext, + getSearchIndex, + getSearchIndexFromContext, + getView, +} from "./query" +export { translatePath, translatePathFromContext } from "./translation" export { deserialize, + // buildHeaders, buildUrl, getJsonApiIndex, getJsonApiPathForResourceType, + // getPathFromContext, syncDrupalPreviewRoutes, } from "./utils" -export * from "./client" -export * from "./jsonapi-errors" +export * from "./types" diff --git a/packages/next-drupal/src/navigation.ts b/packages/next-drupal/src/navigation.ts new file mode 100644 index 00000000..33d5f232 --- /dev/null +++ b/packages/next-drupal/src/navigation.ts @@ -0,0 +1 @@ +export { useMenu } from "./navigation/use-menu" diff --git a/packages/next-drupal/src/use-menu.tsx b/packages/next-drupal/src/navigation/use-menu.tsx similarity index 90% rename from packages/next-drupal/src/use-menu.tsx rename to packages/next-drupal/src/navigation/use-menu.tsx index 441a2a43..b850e293 100644 --- a/packages/next-drupal/src/use-menu.tsx +++ b/packages/next-drupal/src/navigation/use-menu.tsx @@ -1,7 +1,7 @@ import { useRouter } from "next/router" import { useEffect, useState } from "react" -import { getMenu } from "./get-menu" -import type { DrupalMenuLinkContent } from "./types" +import { getMenu } from "../query/get-menu" +import type { DrupalMenuLinkContent } from "../types" export function useMenu( name: string diff --git a/packages/next-drupal/src/preview.ts b/packages/next-drupal/src/preview.ts index 224077b0..bc2589c7 100644 --- a/packages/next-drupal/src/preview.ts +++ b/packages/next-drupal/src/preview.ts @@ -1,4 +1,4 @@ -import { getResourceByPath } from "./get-resource" +import { getResourceByPath } from "./query/get-resource" import type { NextApiRequest, NextApiResponse } from "next" import type { JsonApiWithLocaleOptions } from "./types" diff --git a/packages/next-drupal/src/query.ts b/packages/next-drupal/src/query.ts new file mode 100644 index 00000000..c4080fd6 --- /dev/null +++ b/packages/next-drupal/src/query.ts @@ -0,0 +1,18 @@ +export { getAccessToken } from "./query/get-access-token" +export { getMenu } from "./query/get-menu" +export { getPathsFromContext } from "./query/get-paths" +export { + getResource, + getResourceByPath, + getResourceFromContext, +} from "./query/get-resource" +export { + getResourceCollection, + getResourceCollectionFromContext, +} from "./query/get-resource-collection" +export { getResourceTypeFromContext } from "./query/get-resource-type" +export { + getSearchIndex, + getSearchIndexFromContext, +} from "./query/get-search-index" +export { getView } from "./query/get-view" diff --git a/packages/next-drupal/src/get-cache.ts b/packages/next-drupal/src/query/cache.ts similarity index 100% rename from packages/next-drupal/src/get-cache.ts rename to packages/next-drupal/src/query/cache.ts diff --git a/packages/next-drupal/src/get-access-token.ts b/packages/next-drupal/src/query/get-access-token.ts similarity index 92% rename from packages/next-drupal/src/get-access-token.ts rename to packages/next-drupal/src/query/get-access-token.ts index 6e718b00..0fcb09d0 100644 --- a/packages/next-drupal/src/get-access-token.ts +++ b/packages/next-drupal/src/query/get-access-token.ts @@ -1,5 +1,5 @@ -import { cache } from "./get-cache" -import type { AccessToken } from "./types" +import { cache } from "./cache" +import type { AccessToken } from "../types" const CACHE_KEY = "NEXT_DRUPAL_ACCESS_TOKEN" diff --git a/packages/next-drupal/src/get-menu.ts b/packages/next-drupal/src/query/get-menu.ts similarity index 88% rename from packages/next-drupal/src/get-menu.ts rename to packages/next-drupal/src/query/get-menu.ts index bee99b21..fe2db2c5 100644 --- a/packages/next-drupal/src/get-menu.ts +++ b/packages/next-drupal/src/query/get-menu.ts @@ -1,9 +1,11 @@ -import { buildHeaders, buildUrl, deserialize } from "./utils" +import { buildHeaders } from "../utils/build-headers" +import { buildUrl } from "../utils/build-url" +import { deserialize } from "../utils/deserialize" import type { AccessToken, DrupalMenuLinkContent, JsonApiWithLocaleOptions, -} from "./types" +} from "../types" export async function getMenu( name: string, diff --git a/packages/next-drupal/src/get-paths.ts b/packages/next-drupal/src/query/get-paths.ts similarity index 97% rename from packages/next-drupal/src/get-paths.ts rename to packages/next-drupal/src/query/get-paths.ts index 62f4b341..a2bbfe01 100644 --- a/packages/next-drupal/src/get-paths.ts +++ b/packages/next-drupal/src/query/get-paths.ts @@ -1,6 +1,6 @@ import { getResourceCollection } from "./get-resource-collection" import type { GetStaticPathsContext, GetStaticPathsResult } from "next" -import type { AccessToken, JsonApiParams, Locale } from "./types" +import type { AccessToken, JsonApiParams, Locale } from "../types" export async function getPathsFromContext( types: string | string[], diff --git a/packages/next-drupal/src/get-resource-collection.ts b/packages/next-drupal/src/query/get-resource-collection.ts similarity index 85% rename from packages/next-drupal/src/get-resource-collection.ts rename to packages/next-drupal/src/query/get-resource-collection.ts index 9c182351..933d3313 100644 --- a/packages/next-drupal/src/get-resource-collection.ts +++ b/packages/next-drupal/src/query/get-resource-collection.ts @@ -1,16 +1,14 @@ -import { - buildHeaders, - buildUrl, - deserialize, - getJsonApiPathForResourceType, -} from "./utils" +import { buildHeaders } from "../utils/build-headers" +import { buildUrl } from "../utils/build-url" +import { deserialize } from "../utils/deserialize" +import { getJsonApiPathForResourceType } from "../utils/get-json-api-path-for-resource-type" import type { GetStaticPropsContext } from "next" import type { AccessToken, JsonApiParams, JsonApiResource, JsonApiWithLocaleOptions, -} from "./types" +} from "../types" export async function getResourceCollection( type: string, diff --git a/packages/next-drupal/src/get-resource-type.ts b/packages/next-drupal/src/query/get-resource-type.ts similarity index 76% rename from packages/next-drupal/src/get-resource-type.ts rename to packages/next-drupal/src/query/get-resource-type.ts index fb3ee9d4..8bc9a1cd 100644 --- a/packages/next-drupal/src/get-resource-type.ts +++ b/packages/next-drupal/src/query/get-resource-type.ts @@ -1,6 +1,6 @@ -import { translatePathFromContext } from "./translate-path" +import { translatePathFromContext } from "../translation/translate-path" import type { GetStaticPropsContext } from "next" -import type { AccessToken } from "./types" +import type { AccessToken } from "../types" export async function getResourceTypeFromContext( context: GetStaticPropsContext, diff --git a/packages/next-drupal/src/get-resource.ts b/packages/next-drupal/src/query/get-resource.ts similarity index 93% rename from packages/next-drupal/src/get-resource.ts rename to packages/next-drupal/src/query/get-resource.ts index cc6e28f9..02f54a7d 100644 --- a/packages/next-drupal/src/get-resource.ts +++ b/packages/next-drupal/src/query/get-resource.ts @@ -1,18 +1,16 @@ import { stringify } from "qs" -import { - buildHeaders, - buildUrl, - deserialize, - getJsonApiPathForResourceType, - getPathFromContext, -} from "./utils" +import { buildHeaders } from "../utils/build-headers" +import { buildUrl } from "../utils/build-url" +import { deserialize } from "../utils/deserialize" +import { getJsonApiPathForResourceType } from "../utils/get-json-api-path-for-resource-type" +import { getPathFromContext } from "../utils/get-path-from-context" import type { GetStaticPropsContext } from "next" import type { AccessToken, JsonApiParams, JsonApiResource, JsonApiWithLocaleOptions, -} from "./types" +} from "../types" export async function getResourceFromContext( type: string, diff --git a/packages/next-drupal/src/get-search-index.ts b/packages/next-drupal/src/query/get-search-index.ts similarity index 88% rename from packages/next-drupal/src/get-search-index.ts rename to packages/next-drupal/src/query/get-search-index.ts index a40a5433..aa087189 100644 --- a/packages/next-drupal/src/get-search-index.ts +++ b/packages/next-drupal/src/query/get-search-index.ts @@ -1,10 +1,12 @@ -import { buildHeaders, buildUrl, deserialize } from "./utils" +import { buildHeaders } from "../utils/build-headers" +import { buildUrl } from "../utils/build-url" +import { deserialize } from "../utils/deserialize" import type { GetStaticPropsContext } from "next" import type { AccessToken, JsonApiResource, JsonApiWithLocaleOptions, -} from "./types" +} from "../types" export async function getSearchIndex( name: string, diff --git a/packages/next-drupal/src/get-view.ts b/packages/next-drupal/src/query/get-view.ts similarity index 82% rename from packages/next-drupal/src/get-view.ts rename to packages/next-drupal/src/query/get-view.ts index 8241b0c6..eef147d5 100644 --- a/packages/next-drupal/src/get-view.ts +++ b/packages/next-drupal/src/query/get-view.ts @@ -1,5 +1,7 @@ -import { buildHeaders, buildUrl, deserialize } from "./utils" -import type { AccessToken, JsonApiWithLocaleOptions } from "./types" +import { buildHeaders } from "../utils/build-headers" +import { buildUrl } from "../utils/build-url" +import { deserialize } from "../utils/deserialize" +import type { AccessToken, JsonApiWithLocaleOptions } from "../types" export async function getView( name: string, diff --git a/packages/next-drupal/src/translation.ts b/packages/next-drupal/src/translation.ts new file mode 100644 index 00000000..e87b8b6b --- /dev/null +++ b/packages/next-drupal/src/translation.ts @@ -0,0 +1,4 @@ +export { + translatePath, + translatePathFromContext, +} from "./translation/translate-path" diff --git a/packages/next-drupal/src/translate-path.ts b/packages/next-drupal/src/translation/translate-path.ts similarity index 78% rename from packages/next-drupal/src/translate-path.ts rename to packages/next-drupal/src/translation/translate-path.ts index ace83fc0..b55f5049 100644 --- a/packages/next-drupal/src/translate-path.ts +++ b/packages/next-drupal/src/translation/translate-path.ts @@ -1,6 +1,8 @@ -import { buildHeaders, buildUrl, getPathFromContext } from "./utils" +import { buildHeaders } from "../utils/build-headers" +import { buildUrl } from "../utils/build-url" +import { getPathFromContext } from "../utils/get-path-from-context" import type { GetStaticPropsContext } from "next" -import type { AccessToken, DrupalTranslatedPath } from "./types" +import type { AccessToken, DrupalTranslatedPath } from "../types" export async function translatePath( path: string, diff --git a/packages/next-drupal/src/utils.ts b/packages/next-drupal/src/utils.ts index 7810601a..f5539b7c 100644 --- a/packages/next-drupal/src/utils.ts +++ b/packages/next-drupal/src/utils.ts @@ -1,136 +1,7 @@ -import Jsona from "jsona" -import { stringify } from "qs" -import { getAccessToken } from "./get-access-token" -import type { GetStaticPropsContext } from "next" -import type { AccessToken, Locale } from "./types" - -const JSONAPI_PREFIX = process.env.DRUPAL_JSONAPI_PREFIX || "/jsonapi" - -const dataFormatter = new Jsona() - -export function deserialize(body, options?) { - if (!body) return null - - return dataFormatter.deserialize(body, options) -} - -export async function getJsonApiPathForResourceType( - type: string, - locale?: Locale -) { - const index = await getJsonApiIndex(locale) - - return index?.links[type]?.href -} - -export async function getJsonApiIndex( - locale?: Locale, - options?: { - accessToken?: AccessToken - } -): Promise<{ - links: { - [type: string]: { - href: string - } - } -}> { - const url = buildUrl( - locale ? `/${locale}${JSONAPI_PREFIX}` : `${JSONAPI_PREFIX}` - ) - - // As per https://www.drupal.org/node/2984034 /jsonapi is public. - // We only call buildHeaders if accessToken or locale is explicitly set. - // This is for rare cases where /jsonapi might be protected. - const response = await fetch(url.toString(), { - headers: - locale || options - ? await buildHeaders(options) - : { - "Content-Type": "application/json", - }, - }) - - if (!response.ok) { - throw new Error(response.statusText) - } - - return await response.json() -} - -export function buildUrl( - path: string, - params?: string | Record | URLSearchParams -): URL { - const url = new URL( - path.charAt(0) === "/" - ? `${process.env.NEXT_PUBLIC_DRUPAL_BASE_URL}${path}` - : path - ) - - if (params) { - // Use instead URLSearchParams for nested params. - url.search = stringify(params) - } - - return url -} - -export async function buildHeaders({ - accessToken, - headers = { - "Content-Type": "application/json", - }, -}: { - accessToken?: AccessToken - headers?: RequestInit["headers"] -} = {}): Promise { - // This allows an access_token (preferrably long-lived) to be set directly on the env. - // This reduces the number of OAuth call to the Drupal server. - // Intentionally marked as unstable for now. - if (process.env.UNSTABLE_DRUPAL_ACCESS_TOKEN) { - headers[ - "Authorization" - ] = `Bearer ${process.env.UNSTABLE_DRUPAL_ACCESS_TOKEN}` - - return headers - } - - const token = accessToken || (await getAccessToken()) - if (token) { - headers["Authorization"] = `Bearer ${token.access_token}` - } - - return headers -} - -export function getPathFromContext( - context: GetStaticPropsContext, - prefix = "" -) { - let { slug } = context.params - - slug = Array.isArray(slug) - ? slug.map((s) => encodeURIComponent(s)).join("/") - : slug - - // Handle locale. - if (context.locale && context.locale !== context.defaultLocale) { - slug = `/${context.locale}/${slug}` - } - - return !slug - ? process.env.DRUPAL_FRONT_PAGE - : prefix - ? `${prefix}/${slug}` - : slug -} - -export function syncDrupalPreviewRoutes(path) { - if (window && window.top !== window.self) { - window.parent.postMessage( - { type: "NEXT_DRUPAL_ROUTE_SYNC", path }, - process.env.NEXT_PUBLIC_DRUPAL_BASE_URL - ) - } -} +export { buildHeaders } from "./utils/build-headers" +export { buildUrl } from "./utils/build-url" +export { deserialize } from "./utils/deserialize" +export { getJsonApiIndex } from "./utils/get-json-api-index" +export { getJsonApiPathForResourceType } from "./utils/get-json-api-path-for-resource-type" +export { getPathFromContext } from "./utils/get-path-from-context" +export { syncDrupalPreviewRoutes } from "./utils/sync-drupal-preview-routes" diff --git a/packages/next-drupal/src/utils/build-headers.ts b/packages/next-drupal/src/utils/build-headers.ts new file mode 100644 index 00000000..dd83a053 --- /dev/null +++ b/packages/next-drupal/src/utils/build-headers.ts @@ -0,0 +1,30 @@ +import { getAccessToken } from "../query/get-access-token" +import type { AccessToken } from "../types" + +export async function buildHeaders({ + accessToken, + headers = { + "Content-Type": "application/json", + }, +}: { + accessToken?: AccessToken + headers?: RequestInit["headers"] +} = {}): Promise { + // This allows an access_token (preferrably long-lived) to be set directly on the env. + // This reduces the number of OAuth call to the Drupal server. + // Intentionally marked as unstable for now. + if (process.env.UNSTABLE_DRUPAL_ACCESS_TOKEN) { + headers[ + "Authorization" + ] = `Bearer ${process.env.UNSTABLE_DRUPAL_ACCESS_TOKEN}` + + return headers + } + + const token = accessToken || (await getAccessToken()) + if (token) { + headers["Authorization"] = `Bearer ${token.access_token}` + } + + return headers +} diff --git a/packages/next-drupal/src/utils/build-url.ts b/packages/next-drupal/src/utils/build-url.ts new file mode 100644 index 00000000..5c7fc5f4 --- /dev/null +++ b/packages/next-drupal/src/utils/build-url.ts @@ -0,0 +1,19 @@ +import { stringify } from "qs" + +export function buildUrl( + path: string, + params?: string | Record | URLSearchParams +): URL { + const url = new URL( + path.charAt(0) === "/" + ? `${process.env.NEXT_PUBLIC_DRUPAL_BASE_URL}${path}` + : path + ) + + if (params) { + // Use instead URLSearchParams for nested params. + url.search = stringify(params) + } + + return url +} diff --git a/packages/next-drupal/src/utils/deserialize.ts b/packages/next-drupal/src/utils/deserialize.ts new file mode 100644 index 00000000..4b721831 --- /dev/null +++ b/packages/next-drupal/src/utils/deserialize.ts @@ -0,0 +1,9 @@ +import Jsona from "jsona" + +const dataFormatter = new Jsona() + +export function deserialize(body, options?) { + if (!body) return null + + return dataFormatter.deserialize(body, options) +} diff --git a/packages/next-drupal/src/utils/get-json-api-index.ts b/packages/next-drupal/src/utils/get-json-api-index.ts new file mode 100644 index 00000000..08bea8b7 --- /dev/null +++ b/packages/next-drupal/src/utils/get-json-api-index.ts @@ -0,0 +1,40 @@ +import { buildHeaders } from "./build-headers" +import { buildUrl } from "./build-url" +import type { AccessToken, Locale } from "../types" + +const JSONAPI_PREFIX = process.env.DRUPAL_JSONAPI_PREFIX || "/jsonapi" + +export async function getJsonApiIndex( + locale?: Locale, + options?: { + accessToken?: AccessToken + } +): Promise<{ + links: { + [type: string]: { + href: string + } + } +}> { + const url = buildUrl( + locale ? `/${locale}${JSONAPI_PREFIX}` : `${JSONAPI_PREFIX}` + ) + + // As per https://www.drupal.org/node/2984034 /jsonapi is public. + // We only call buildHeaders if accessToken or locale is explicitly set. + // This is for rare cases where /jsonapi might be protected. + const response = await fetch(url.toString(), { + headers: + locale || options + ? await buildHeaders(options) + : { + "Content-Type": "application/json", + }, + }) + + if (!response.ok) { + throw new Error(response.statusText) + } + + return await response.json() +} diff --git a/packages/next-drupal/src/utils/get-json-api-path-for-resource-type.ts b/packages/next-drupal/src/utils/get-json-api-path-for-resource-type.ts new file mode 100644 index 00000000..1043fcdb --- /dev/null +++ b/packages/next-drupal/src/utils/get-json-api-path-for-resource-type.ts @@ -0,0 +1,11 @@ +import { getJsonApiIndex } from "./get-json-api-index" +import type { Locale } from "../types" + +export async function getJsonApiPathForResourceType( + type: string, + locale?: Locale +) { + const index = await getJsonApiIndex(locale) + + return index?.links[type]?.href +} diff --git a/packages/next-drupal/src/utils/get-path-from-context.ts b/packages/next-drupal/src/utils/get-path-from-context.ts new file mode 100644 index 00000000..81a4e156 --- /dev/null +++ b/packages/next-drupal/src/utils/get-path-from-context.ts @@ -0,0 +1,23 @@ +import type { GetStaticPropsContext } from "next" + +export function getPathFromContext( + context: GetStaticPropsContext, + prefix = "" +) { + let { slug } = context.params + + slug = Array.isArray(slug) + ? slug.map((s) => encodeURIComponent(s)).join("/") + : slug + + // Handle locale. + if (context.locale && context.locale !== context.defaultLocale) { + slug = `/${context.locale}/${slug}` + } + + return !slug + ? process.env.DRUPAL_FRONT_PAGE + : prefix + ? `${prefix}/${slug}` + : slug +} diff --git a/packages/next-drupal/src/utils/sync-drupal-preview-routes.ts b/packages/next-drupal/src/utils/sync-drupal-preview-routes.ts new file mode 100644 index 00000000..df90c1de --- /dev/null +++ b/packages/next-drupal/src/utils/sync-drupal-preview-routes.ts @@ -0,0 +1,8 @@ +export function syncDrupalPreviewRoutes(path) { + if (window && window.top !== window.self) { + window.parent.postMessage( + { type: "NEXT_DRUPAL_ROUTE_SYNC", path }, + process.env.NEXT_PUBLIC_DRUPAL_BASE_URL + ) + } +}