From a216bc2d211dbb056e7226377323a41b9699c86d Mon Sep 17 00:00:00 2001 From: Daniel Cousens <413395+dcousens@users.noreply.github.com> Date: Tue, 26 Sep 2023 14:13:01 +1000 Subject: [PATCH] Tidy up Cloudinary package and add missing changeset (#8836) --- .changeset/fix-cloudinary-init.md | 5 + packages/cloudinary/src/cloudinary.ts | 159 -------------------------- packages/cloudinary/src/index.ts | 92 ++++++++++----- 3 files changed, 70 insertions(+), 186 deletions(-) create mode 100644 .changeset/fix-cloudinary-init.md delete mode 100644 packages/cloudinary/src/cloudinary.ts diff --git a/.changeset/fix-cloudinary-init.md b/.changeset/fix-cloudinary-init.md new file mode 100644 index 00000000000..8143c29cb84 --- /dev/null +++ b/.changeset/fix-cloudinary-init.md @@ -0,0 +1,5 @@ +--- +'@keystone-6/cloudinary': patch +--- + +Remove runtime errors from the Cloudinary field, fall back to the `cloudinary` package for Cloudinary errors diff --git a/packages/cloudinary/src/cloudinary.ts b/packages/cloudinary/src/cloudinary.ts deleted file mode 100644 index 04b49738a87..00000000000 --- a/packages/cloudinary/src/cloudinary.ts +++ /dev/null @@ -1,159 +0,0 @@ -import fs from 'fs'; -import cloudinary from 'cloudinary'; - -export type File = { id: string; filename: string; _meta: cloudinary.UploadApiResponse }; - -export type CloudinaryImageFormat = { - prettyName?: string | null; - width?: string | null; - height?: string | null; - crop?: string | null; - aspect_ratio?: string | null; - gravity?: string | null; - zoom?: string | null; - x?: string | null; - y?: string | null; - format?: string | null; - fetch_format?: string | null; - quality?: string | null; - radius?: string | null; - angle?: string | null; - effect?: string | null; - opacity?: string | null; - border?: string | null; - background?: string | null; - overlay?: string | null; - underlay?: string | null; - default_image?: string | null; - delay?: string | null; - color?: string | null; - color_space?: string | null; - dpr?: string | null; - page?: string | null; - density?: string | null; - flags?: string | null; - transformation?: string | null; -}; - -function uploadStream( - stream: fs.ReadStream, - options: cloudinary.UploadApiOptions -): Promise { - return new Promise((resolve, reject) => { - const cloudinaryStream = cloudinary.v2.uploader.upload_stream(options, (error, result) => { - if (error || !result) { - return reject(error); - } - resolve(result); - }); - - stream.pipe(cloudinaryStream); - }); -} - -export class CloudinaryAdapter { - cloudName: string; - apiKey: string; - apiSecret: string; - folder?: string; - constructor({ - cloudName, - apiKey, - apiSecret, - folder, - }: { - cloudName: string; - apiKey: string; - apiSecret: string; - folder?: string; - }) { - this.cloudName = cloudName; - this.apiKey = apiKey; - this.apiSecret = apiSecret; - this.folder = folder || undefined; - } - - ready() { - return this.cloudName.length > 0 && this.apiKey.length > 0 && this.apiSecret.length > 0; - } - - /** - * Params: { stream, filename, id } - */ - save({ stream, filename, id }: { stream: fs.ReadStream; filename: string; id: string }) { - if (!this.ready()) throw new Error('Cloudinary adapter is not ready'); - - // push to cloudinary - return uploadStream(stream, { - public_id: id, - folder: this.folder, - api_key: this.apiKey, - api_secret: this.apiSecret, - cloud_name: this.cloudName, - }).then(result => ({ - // return the relevant data for the file api - id, - filename, - _meta: result, - })); - } - - /** - * Deletes the given file from cloudinary - * @param file File field data - * @param options Delete options passed to cloudinary. - * For available options refer to the [Cloudinary destroy API](https://cloudinary.com/documentation/image_upload_api_reference#destroy_method). - */ - delete(file?: File, options = {}) { - if (!this.ready()) throw new Error('Cloudinary adapter is not ready'); - - const destroyOptions = { - // Auth - api_key: this.apiKey, - api_secret: this.apiSecret, - cloud_name: this.cloudName, - ...options, - }; - - return new Promise((resolve, reject) => { - if (file) { - // @ts-ignore - cloudinary.v2.uploader.destroy(file._meta.public_id, destroyOptions, (error, result) => { - if (error) { - reject(error); - } else { - resolve(result); - } - }); - } else { - reject(new Error("Missing required argument 'file'.")); - } - }); - } - - publicUrl(file?: File) { - return file?._meta?.secure_url || null; - } - - publicUrlTransformed(file: File, options: CloudinaryImageFormat = {}) { - if (!file._meta) return null; - - const { prettyName, ...transformation } = options; - - // No formatting options provided, return the publicUrl field - if (!Object.keys(transformation).length) return this.publicUrl(file); - - const { public_id, format } = file._meta; - - // ref https://github.com/cloudinary/cloudinary_npm/blob/439586eac73cee7f2803cf19f885e98f237183b3/src/utils.coffee#L472 - // @ts-ignore - return cloudinary.url(public_id, { - type: 'upload', - format, - secure: true, - url_suffix: prettyName, - transformation, - cloud_name: this.cloudName, - }); - } -} diff --git a/packages/cloudinary/src/index.ts b/packages/cloudinary/src/index.ts index a207ebc0c1c..8d0745b0a06 100644 --- a/packages/cloudinary/src/index.ts +++ b/packages/cloudinary/src/index.ts @@ -1,13 +1,8 @@ import { randomBytes } from 'node:crypto'; -import { - CommonFieldConfig, - BaseListTypeInfo, - FieldTypeFunc, - jsonFieldTypePolyfilledForSQLite, -} from '@keystone-6/core/types'; +import type { CommonFieldConfig, BaseListTypeInfo, FieldTypeFunc } from '@keystone-6/core/types'; +import { jsonFieldTypePolyfilledForSQLite } from '@keystone-6/core/types'; import { graphql } from '@keystone-6/core'; import cloudinary from 'cloudinary'; -import { CloudinaryAdapter } from './cloudinary'; type StoredFile = { id: string; @@ -106,21 +101,20 @@ export const outputType: graphql.ObjectType = }, }); -export const cloudinaryImage = - ({ - cloudinary, - ...config - }: CloudinaryImageFieldConfig): FieldTypeFunc => - meta => { +// TODO: no delete support +export function cloudinaryImage({ + cloudinary: cloudinaryConfig, + ...config +}: CloudinaryImageFieldConfig): FieldTypeFunc { + return meta => { if ((config as any).isIndexed === 'unique') { throw Error("isIndexed: 'unique' is not a supported option for field type cloudinaryImage"); } - const adapter = new CloudinaryAdapter(cloudinary); const inputArg = graphql.arg({ type: graphql.Upload }); - const resolveInput = async ( + async function resolveInput( uploadData: graphql.InferValueFromArg - ): Promise => { + ): Promise { if (uploadData === null) { return meta.provider === 'postgresql' || meta.provider === 'mysql' ? 'DbNull' : null; } @@ -131,21 +125,42 @@ export const cloudinaryImage = const { createReadStream, filename: originalFilename, mimetype, encoding } = await uploadData; const stream = createReadStream(); + // TODO: FIXME: stream can be null if (!stream) { - // TODO: FIXME: Handle when stream is null. Can happen when: - // Updating some other part of the item, but not the file (gets null - // because no File DOM element is uploaded) return undefined; } - const { id, filename, _meta } = await adapter.save({ - stream, - filename: originalFilename, - id: randomBytes(20).toString('base64url'), + const id = randomBytes(20).toString('base64url'); + const _meta = await new Promise((resolve, reject) => { + const cloudinaryStream = cloudinary.v2.uploader.upload_stream( + { + public_id: id, + folder: cloudinaryConfig.folder, + api_key: cloudinaryConfig.apiKey, + api_secret: cloudinaryConfig.apiSecret, + cloud_name: cloudinaryConfig.cloudName, + }, + (error, result) => { + if (error || !result) { + return reject(error); + } + resolve(result); + } + ); + + stream.pipe(cloudinaryStream); }); - return { id, filename, originalFilename, mimetype, encoding, _meta }; - }; + return { + id, + filename: originalFilename, + originalFilename, + mimetype, + encoding, + _meta, + }; + } + return jsonFieldTypePolyfilledForSQLite( meta.provider, { @@ -163,14 +178,36 @@ export const cloudinaryImage = } const val = value as any; return { - publicUrl: adapter.publicUrl(val), + publicUrl: val?._meta?.secure_url ?? null, publicUrlTransformed: ({ transformation, }: { transformation: graphql.InferValueFromArg< graphql.Arg >; - }) => adapter.publicUrlTransformed(val, transformation ?? {}), + }) => { + if (!val._meta) return null; + + const { prettyName, ...rest } = transformation ?? {}; + + // no formatting options provided, return the publicUrl field + if (!Object.keys(rest).length) { + return val?._meta?.secure_url ?? null; + } + + const { public_id, format } = val._meta; + + // ref https://github.com/cloudinary/cloudinary_npm/blob/439586eac73cee7f2803cf19f885e98f237183b3/src/utils.coffee#L472 + // @ts-ignore + return cloudinary.url(public_id, { + type: 'upload', + format, + secure: true, + url_suffix: prettyName, + transformation, + cloud_name: cloudinaryConfig.cloudName, + }); + }, ...val, }; }, @@ -182,3 +219,4 @@ export const cloudinaryImage = } ); }; +}