diff --git a/.github/workflows/publish-package.yaml b/.github/workflows/publish-package.yaml index af5658f3..4bb402b7 100644 --- a/.github/workflows/publish-package.yaml +++ b/.github/workflows/publish-package.yaml @@ -4,6 +4,7 @@ on: push: tags: - 'clockify-enhanced-*' + - 'google-enhanced-*' env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/nodes/google-enhanced/.eslintrc.json b/nodes/google-enhanced/.eslintrc.json new file mode 100644 index 00000000..b31cf71b --- /dev/null +++ b/nodes/google-enhanced/.eslintrc.json @@ -0,0 +1,30 @@ +{ + "extends": ["../../.eslintrc.base.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.json"], + "parser": "jsonc-eslint-parser", + "rules": { + "@nx/dependency-checks": [ + "error", + { + "ignoredDependencies": ["express", "jest-mock-extended"] + } + ] + } + } + ] +} diff --git a/nodes/google-enhanced/README.md b/nodes/google-enhanced/README.md new file mode 100644 index 00000000..2ef718ce --- /dev/null +++ b/nodes/google-enhanced/README.md @@ -0,0 +1,52 @@ +# @skriptfabrik/n8n-nodes-google-enhanced + +[![NPM Version](https://img.shields.io/npm/v/@skriptfabrik/n8n-nodes-google-enhanced)](https://www.npmjs.com/package/@skriptfabrik/n8n-nodes-google-enhanced) +[![NPM Downloads](https://img.shields.io/npm/dt/@skriptfabrik/n8n-nodes-google-enhanced)](https://www.npmjs.com/package/@skriptfabrik/n8n-nodes-google-enhanced) + +> Enhanced Google community nodes for your [n8n](https://n8n.io/) workflows + +This is an n8n community node. It lets you use [Google actions](https://docs.n8n.io/integrations/builtin/app-nodes/) with +service account credentials in your n8n workflows. Although the authentication method recommended by n8n is OAuth2, this +is not suitable in an environment where no user interaction is intended. + +[n8n](https://n8n.io/) is a [fair-code licensed](https://docs.n8n.io/reference/license/) workflow automation platform. + +[Installation](#installation) +[Operations](#operations) +[Credentials](#credentials) +[Compatibility](#compatibility) +[Resources](#resources) + +## Installation + +Follow the [installation guide](https://docs.n8n.io/integrations/community-nodes/installation/) in the n8n community +nodes documentation. + +1. Go to **Settings > Community Nodes**. +2. Select **Install**. +3. Enter `@skriptfabrik/n8n-nodes-google-enhanced` in **Enter npm package name**. +4. Agree to the [risks](https://docs.n8n.io/integrations/community-nodes/risks/) of using community nodes: select + **I understand the risks of installing unverified code from a public source**. +5. Select **Install**. + +After installing the node, you can use it like any other node. n8n displays the node in search results in the **Nodes** panel. + +## Operations + +It supports these operations with Service Account credentials: + +- Create, delete, get, list, update Google Cloud Storage buckets and objects + +## Credentials + +You can use the built-in [Google credentials](https://docs.n8n.io/integrations/builtin/credentials/google/) to +authenticate with Google. + +## Compatibility + +Tested against n8n version 1.0+. + +## Resources + +- [n8n community nodes documentation](https://docs.n8n.io/integrations/community-nodes/) +- [Google Cloud Storage API (v1)](https://cloud.google.com/storage/docs/json_api/v1) diff --git a/nodes/google-enhanced/jest.config.ts b/nodes/google-enhanced/jest.config.ts new file mode 100644 index 00000000..970f4dcd --- /dev/null +++ b/nodes/google-enhanced/jest.config.ts @@ -0,0 +1,11 @@ +/* eslint-disable */ +export default { + displayName: 'n8n-nodes-google-enhanced', + preset: '../../jest.preset.js', + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../coverage/nodes/google-enhanced', +}; diff --git a/nodes/google-enhanced/package.json b/nodes/google-enhanced/package.json new file mode 100644 index 00000000..18142b7d --- /dev/null +++ b/nodes/google-enhanced/package.json @@ -0,0 +1,43 @@ +{ + "name": "@skriptfabrik/n8n-nodes-google-enhanced", + "version": "0.1.0", + "description": "Enhanced Google community nodes for n8n", + "keywords": [ + "google", + "google-cloud-storage", + "n8n", + "n8n-community-node", + "n8n-community-node-package" + ], + "license": "MIT", + "homepage": "https://github.com/skriptfabrik/n8n-nodes/blob/main/nodes/google-enhanced/README.md", + "author": { + "name": "skriptfabrik", + "email": "info@skriptfabrik.com" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/skriptfabrik/n8n-nodes.git" + }, + "main": "./src/index.js", + "typings": "./src/index.d.ts", + "type": "commonjs", + "n8n": { + "n8nNodesApiVersion": 1, + "credentials": [], + "nodes": [ + "src/nodes/GoogleCloudStorageEnhanced/GoogleCloudStorageEnhanced.node.js" + ] + }, + "dependencies": { + "form-data": "^4.0.0", + "jsonwebtoken": "^9.0.0", + "moment-timezone": "^0.5.28", + "n8n-nodes-base": "^1.29.1", + "n8n-workflow": "^1.29.1", + "tslib": "^2.6.2" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org" + } +} diff --git a/nodes/google-enhanced/project.json b/nodes/google-enhanced/project.json new file mode 100644 index 00000000..9cd189ec --- /dev/null +++ b/nodes/google-enhanced/project.json @@ -0,0 +1,43 @@ +{ + "name": "n8n-nodes-google-enhanced", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "nodes/google-enhanced/src", + "projectType": "library", + "targets": { + "build": { + "executor": "@nx/js:tsc", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/nodes/google-enhanced", + "tsConfig": "nodes/google-enhanced/tsconfig.lib.json", + "packageJson": "nodes/google-enhanced/package.json", + "main": "nodes/google-enhanced/src/index.ts", + "assets": [ + "nodes/google-enhanced/src/nodes/*/*.svg", + "nodes/google-enhanced/src/nodes/*/*.json", + "nodes/google-enhanced/*.md" + ] + } + }, + "link": { + "executor": "nx:run-commands", + "options": { + "cwd": "dist/nodes/google-enhanced", + "command": "npm link --no-audit" + }, + "dependsOn": ["build"] + }, + "install": { + "executor": "nx:run-commands", + "options": { + "command": "node tools/scripts/install.mjs n8n-nodes-google-enhanced" + }, + "dependsOn": ["link"] + }, + "publish": { + "command": "node tools/scripts/publish.mjs n8n-nodes-google-enhanced {args.ver} {args.tag}", + "dependsOn": ["build"] + } + }, + "tags": [] +} diff --git a/nodes/google-enhanced/src/index.ts b/nodes/google-enhanced/src/index.ts new file mode 100644 index 00000000..bf2d1721 --- /dev/null +++ b/nodes/google-enhanced/src/index.ts @@ -0,0 +1 @@ +export * from './nodes/GoogleCloudStorageEnhanced/GoogleCloudStorageEnhanced.node'; diff --git a/nodes/google-enhanced/src/nodes/GenericFunctions.spec.ts b/nodes/google-enhanced/src/nodes/GenericFunctions.spec.ts new file mode 100644 index 00000000..1d8baead --- /dev/null +++ b/nodes/google-enhanced/src/nodes/GenericFunctions.spec.ts @@ -0,0 +1,160 @@ +import { mockClear, mockDeep } from 'jest-mock-extended'; +import * as jwt from 'jsonwebtoken'; +import moment from 'moment-timezone'; +import type { IExecuteFunctions } from 'n8n-workflow'; +import { + createMultipartForm, + parseBodyData, + requestAccessToken, + requestServiceAccount, +} from './GenericFunctions'; + +jest.mock('jsonwebtoken'); +jest.mock('moment-timezone'); + +describe('GenericFunctions', () => { + const executeFunctions = mockDeep(); + const mockedJwt = jest.mocked(jwt); + const mockedMoment = jest.mocked(moment); + const momentInstance = mockDeep(); + + beforeEach(() => { + mockedMoment.mockReturnValue(momentInstance); + }); + + afterEach(() => { + mockClear(executeFunctions); + mockClear(mockedMoment); + mockClear(momentInstance); + }); + + it('should create multipart form', () => { + const metadata = { + items: '[{"name": "__test__"}]', + }; + + expect( + createMultipartForm(metadata, '__content__', 'application/json', 100), + ).toBeDefined(); + }); + + it('should parse body data', () => { + const bodyData = { + validItems: '[{"name": "__test__"}]', + invalidItems: '{name: "__test__"}', + }; + + expect( + parseBodyData(bodyData, ['validItems', 'invalidItems', 'undefinedItems']), + ).toEqual({ + validItems: [{ name: '__test__' }], + invalidItems: '{name: "__test__"}', + }); + }); + + it('should request access token', () => { + momentInstance.unix.mockReturnValue(300); + executeFunctions.getCredentials.calledWith('googleApi').mockResolvedValue({ + email: '__email__', + privateKey: '__private_key__', + }); + mockedJwt.sign.mockImplementation(() => '__jwt__'); + executeFunctions.helpers.request.mockResolvedValueOnce({ + access_token: '__access_token__', + }); + + expect( + requestAccessToken.call(executeFunctions, 'googleApi', [ + 'https://www.googleapis.com/auth/cloud-platform', + 'https://www.googleapis.com/auth/cloud-platform.read-only', + 'https://www.googleapis.com/auth/devstorage.full_control', + 'https://www.googleapis.com/auth/devstorage.read_only', + 'https://www.googleapis.com/auth/devstorage.read_write', + ]), + ).resolves.toEqual({ + access_token: '__access_token__', + }); + }); + + it('should request access token with delegated email', () => { + momentInstance.unix.mockReturnValue(300); + executeFunctions.getCredentials.calledWith('googleApi').mockResolvedValue({ + delegatedEmail: '__delegated_email__', + privateKey: '__private_key__', + }); + mockedJwt.sign.mockImplementation(() => '__jwt__'); + executeFunctions.helpers.request.mockResolvedValueOnce({ + access_token: '__access_token__', + }); + + expect( + requestAccessToken.call(executeFunctions, 'googleApi', [ + 'https://www.googleapis.com/auth/cloud-platform', + 'https://www.googleapis.com/auth/cloud-platform.read-only', + 'https://www.googleapis.com/auth/devstorage.full_control', + 'https://www.googleapis.com/auth/devstorage.read_only', + 'https://www.googleapis.com/auth/devstorage.read_write', + ]), + ).resolves.toEqual({ + access_token: '__access_token__', + }); + }); + + it('should request service account', () => { + const returnData = { + kind: 'storage#bucket', + selfLink: 'https://www.googleapis.com/storage/v1/b/__bucket__', + id: '__bucket__', + name: '__bucket__', + projectNumber: '1234567890', + metageneration: '2', + location: 'EU', + storageClass: 'STANDARD', + etag: 'CAI=', + timeCreated: '2024-02-26T12:29:08.082Z', + updated: '2024-02-26T12:55:47.601Z', + iamConfiguration: { + bucketPolicyOnly: { + enabled: true, + lockedTime: '2024-05-26T12:29:08.082Z', + }, + uniformBucketLevelAccess: { + enabled: true, + lockedTime: '2024-05-26T12:29:08.082Z', + }, + publicAccessPrevention: 'enforced', + }, + locationType: 'multi-region', + rpo: 'DEFAULT', + }; + + momentInstance.unix.mockReturnValue(300); + executeFunctions.getCredentials.calledWith('googleApi').mockResolvedValue({ + email: '__email__', + privateKey: '__private_key__', + }); + mockedJwt.sign.mockImplementation(() => '__jwt__'); + executeFunctions.helpers.request.mockResolvedValueOnce({ + access_token: '__access_token__', + }); + executeFunctions.helpers.request.mockResolvedValueOnce(returnData); + + expect( + requestServiceAccount.call( + executeFunctions, + 'googleApi', + { + baseURL: 'https://storage.googleapis.com/storage/v1', + url: '/b/__bucket__', + }, + [ + 'https://www.googleapis.com/auth/cloud-platform', + 'https://www.googleapis.com/auth/cloud-platform.read-only', + 'https://www.googleapis.com/auth/devstorage.full_control', + 'https://www.googleapis.com/auth/devstorage.read_only', + 'https://www.googleapis.com/auth/devstorage.read_write', + ], + ), + ).resolves.toEqual(returnData); + }); +}); diff --git a/nodes/google-enhanced/src/nodes/GenericFunctions.ts b/nodes/google-enhanced/src/nodes/GenericFunctions.ts new file mode 100644 index 00000000..7bea0a9c --- /dev/null +++ b/nodes/google-enhanced/src/nodes/GenericFunctions.ts @@ -0,0 +1,113 @@ +import FormData from 'form-data'; +import * as jwt from 'jsonwebtoken'; +import moment from 'moment-timezone'; +import { formatPrivateKey } from 'n8n-nodes-base/dist/utils/utilities'; +import type { + IDataObject, + IExecuteFunctions, + ILoadOptionsFunctions, + IRequestOptions, +} from 'n8n-workflow'; +import { Readable } from 'stream'; + +export function createMultipartForm( + metadata: IDataObject, + content: string | Buffer | Readable, + contentType: string, + knownLength: number, +) { + const body = new FormData(); + + body.append('metadata', JSON.stringify(metadata), { + contentType: 'application/json', + }); + + body.append('file', content, { + contentType, + knownLength, + }); + + return body; +} + +export function parseBodyData(bodyData: IDataObject, fields: string[]) { + for (const field of fields) { + if (!bodyData[field]) { + continue; + } + + try { + bodyData[field] = JSON.parse(bodyData[field] as string); + } catch (error) { + continue; + } + } + + return bodyData; +} + +export async function requestAccessToken( + this: IExecuteFunctions | ILoadOptionsFunctions, + credentialsType: string, + scopes: string[], +): Promise { + const now = moment().unix(); + const credentials = await this.getCredentials(credentialsType); + const privateKey = formatPrivateKey(credentials['privateKey'] as string); + + credentials['email'] = ((credentials['email'] as string) || '').trim(); + + const options: IRequestOptions = { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + method: 'POST', + form: { + grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', + assertion: jwt.sign( + { + iss: credentials['email'], + sub: credentials['delegatedEmail'] || credentials['email'], + scope: scopes.join(' '), + aud: 'https://oauth2.googleapis.com/token', + iat: now, + exp: now + 3600, + }, + privateKey, + { + algorithm: 'RS256', + header: { + kid: privateKey, + typ: 'JWT', + alg: 'RS256', + }, + }, + ), + }, + uri: 'https://oauth2.googleapis.com/token', + json: true, + }; + + return this.helpers.request(options); +} + +export async function requestServiceAccount( + this: IExecuteFunctions | ILoadOptionsFunctions, + credentialsType: string, + options: IRequestOptions, + scopes: string[], +): Promise { + const { access_token } = await requestAccessToken.call( + this, + credentialsType, + scopes, + ); + + return this.helpers.request({ + ...options, + headers: { + ...options.headers, + Authorization: `Bearer ${access_token}`, + }, + }); +} diff --git a/nodes/google-enhanced/src/nodes/GoogleCloudStorageEnhanced/GenericFunctions.spec.ts b/nodes/google-enhanced/src/nodes/GoogleCloudStorageEnhanced/GenericFunctions.spec.ts new file mode 100644 index 00000000..37baec38 --- /dev/null +++ b/nodes/google-enhanced/src/nodes/GoogleCloudStorageEnhanced/GenericFunctions.spec.ts @@ -0,0 +1,164 @@ +import { mockClear, mockDeep } from 'jest-mock-extended'; +import type { IExecuteFunctions } from 'n8n-workflow'; +import { requestServiceAccount } from '../GenericFunctions'; +import { googleApiRequest, googleApiRequestAllItems } from './GenericFunctions'; + +jest.mock('jsonwebtoken'); +jest.mock('../GenericFunctions'); + +describe('GenericFunctions', () => { + const executeFunctions = mockDeep(); + const mockedRequestServiceAccount = jest.mocked(requestServiceAccount); + + afterEach(() => { + mockClear(executeFunctions); + mockClear(mockedRequestServiceAccount); + }); + + it('should make an API request with service account to get one item', () => { + const returnData = { + kind: 'storage#bucket', + selfLink: 'https://www.googleapis.com/storage/v1/b/__bucket__', + id: '__bucket__', + name: '__bucket__', + projectNumber: '1234567890', + metageneration: '2', + location: 'EU', + storageClass: 'STANDARD', + etag: 'CAI=', + timeCreated: '2024-02-26T12:29:08.082Z', + updated: '2024-02-26T12:55:47.601Z', + iamConfiguration: { + bucketPolicyOnly: { + enabled: true, + lockedTime: '2024-05-26T12:29:08.082Z', + }, + uniformBucketLevelAccess: { + enabled: true, + lockedTime: '2024-05-26T12:29:08.082Z', + }, + publicAccessPrevention: 'enforced', + }, + locationType: 'multi-region', + rpo: 'DEFAULT', + }; + + executeFunctions.getNodeParameter + .calledWith('authentication', 0, 'serviceAccount') + .mockReturnValue('serviceAccount'); + mockedRequestServiceAccount.mockResolvedValue(returnData); + + expect( + googleApiRequest.call(executeFunctions, 'GET', '/b/__bucket__'), + ).resolves.toEqual(returnData); + + expect(mockedRequestServiceAccount).toHaveBeenCalledWith( + 'googleApi', + expect.any(Object), + [ + 'https://www.googleapis.com/auth/cloud-platform', + 'https://www.googleapis.com/auth/cloud-platform.read-only', + 'https://www.googleapis.com/auth/devstorage.full_control', + 'https://www.googleapis.com/auth/devstorage.read_only', + 'https://www.googleapis.com/auth/devstorage.read_write', + ], + ); + }); + + it('should make an API request with oAuth2 to get one item', () => { + const returnData = { + kind: 'storage#bucket', + selfLink: 'https://www.googleapis.com/storage/v1/b/__bucket__', + id: '__bucket__', + name: '__bucket__', + projectNumber: '1234567890', + metageneration: '2', + location: 'EU', + storageClass: 'STANDARD', + etag: 'CAI=', + timeCreated: '2024-02-26T12:29:08.082Z', + updated: '2024-02-26T12:55:47.601Z', + iamConfiguration: { + bucketPolicyOnly: { + enabled: true, + lockedTime: '2024-05-26T12:29:08.082Z', + }, + uniformBucketLevelAccess: { + enabled: true, + lockedTime: '2024-05-26T12:29:08.082Z', + }, + publicAccessPrevention: 'enforced', + }, + locationType: 'multi-region', + rpo: 'DEFAULT', + }; + + executeFunctions.getNodeParameter + .calledWith('authentication', 0, 'serviceAccount') + .mockReturnValue('oAuth2'); + executeFunctions.helpers.requestOAuth2 + .calledWith('googleApi', expect.any(Object)) + .mockResolvedValue(returnData); + + expect( + googleApiRequest.call( + executeFunctions, + 'GET', + new URL('https://storage.googleapis.com/storage/v1/b/__bucket__'), + undefined, + { alt: 'media' }, + ), + ).resolves.toEqual(returnData); + }); + + it('should make an API request to get all items', () => { + const returnData = [ + { + kind: 'storage#bucket', + selfLink: 'https://www.googleapis.com/storage/v1/b/__bucket__', + id: '__bucket__', + name: '__bucket__', + projectNumber: '1234567890', + metageneration: '2', + location: 'EU', + storageClass: 'STANDARD', + etag: 'CAI=', + timeCreated: '2024-02-26T12:29:08.082Z', + updated: '2024-02-26T12:55:47.601Z', + iamConfiguration: { + bucketPolicyOnly: { + enabled: true, + lockedTime: '2024-05-26T12:29:08.082Z', + }, + uniformBucketLevelAccess: { + enabled: true, + lockedTime: '2024-05-26T12:29:08.082Z', + }, + publicAccessPrevention: 'enforced', + }, + locationType: 'multi-region', + rpo: 'DEFAULT', + }, + ]; + + executeFunctions.getNodeParameter + .calledWith('authentication', 0, 'serviceAccount') + .mockReturnValue('serviceAccount'); + mockedRequestServiceAccount.mockResolvedValue({ items: returnData }); + + expect( + googleApiRequestAllItems.call(executeFunctions, 'GET', '/b'), + ).resolves.toEqual(returnData); + }); + + it('should make an API request to get no items', () => { + executeFunctions.getNodeParameter + .calledWith('authentication', 0, 'serviceAccount') + .mockReturnValue('serviceAccount'); + mockedRequestServiceAccount.mockResolvedValue({}); + + expect( + googleApiRequestAllItems.call(executeFunctions, 'GET', '/b'), + ).resolves.toEqual([]); + }); +}); diff --git a/nodes/google-enhanced/src/nodes/GoogleCloudStorageEnhanced/GenericFunctions.ts b/nodes/google-enhanced/src/nodes/GoogleCloudStorageEnhanced/GenericFunctions.ts new file mode 100644 index 00000000..f296ebfa --- /dev/null +++ b/nodes/google-enhanced/src/nodes/GoogleCloudStorageEnhanced/GenericFunctions.ts @@ -0,0 +1,74 @@ +import type { + IDataObject, + IExecuteFunctions, + IHttpRequestMethods, + ILoadOptionsFunctions, + IRequestOptions, +} from 'n8n-workflow'; +import { requestServiceAccount } from '../GenericFunctions'; + +export async function googleApiRequest( + this: IExecuteFunctions | ILoadOptionsFunctions, + method: IHttpRequestMethods, + url: URL | string, + body?: IDataObject, + qs?: IDataObject, + headers: IDataObject = { 'Content-Type': 'application/json' }, +): Promise { + const authenticationMethod = this.getNodeParameter( + 'authentication', + 0, + 'serviceAccount', + ); + + const options: IRequestOptions = { + ...(url instanceof URL + ? { + uri: `${url}`, + } + : { + baseURL: 'https://storage.googleapis.com/storage/v1', + url: `${url}`, + }), + method, + qs, + headers, + body, + json: true, + ...(qs && qs['alt'] === 'media' && { encoding: 'arraybuffer' }), + }; + + if (authenticationMethod === 'serviceAccount') { + return requestServiceAccount.call(this, 'googleApi', options, [ + 'https://www.googleapis.com/auth/cloud-platform', + 'https://www.googleapis.com/auth/cloud-platform.read-only', + 'https://www.googleapis.com/auth/devstorage.full_control', + 'https://www.googleapis.com/auth/devstorage.read_only', + 'https://www.googleapis.com/auth/devstorage.read_write', + ]); + } + + return this.helpers.requestOAuth2.call(this, 'googleApi', options); +} + +export async function googleApiRequestAllItems( + this: IExecuteFunctions | ILoadOptionsFunctions, + method: string, + url: URL | string, + body?: IDataObject, + qs: IDataObject = {}, +): Promise { + const returnData: IDataObject[] = []; + + let responseData: IDataObject; + + do { + responseData = await googleApiRequest.call(this, method, url, body, qs); + + returnData.push(...((responseData['items'] as IDataObject[]) || [])); + + qs['pageToken'] = responseData['nextPageToken']; + } while (qs['pageToken'] !== undefined); + + return returnData; +} diff --git a/nodes/google-enhanced/src/nodes/GoogleCloudStorageEnhanced/GoogleCloudStorageEnhanced.node.spec.ts b/nodes/google-enhanced/src/nodes/GoogleCloudStorageEnhanced/GoogleCloudStorageEnhanced.node.spec.ts new file mode 100644 index 00000000..3d88f015 --- /dev/null +++ b/nodes/google-enhanced/src/nodes/GoogleCloudStorageEnhanced/GoogleCloudStorageEnhanced.node.spec.ts @@ -0,0 +1,1452 @@ +import FormData from 'form-data'; +import { mock, mockClear, mockDeep } from 'jest-mock-extended'; +import type { + IBinaryData, + IExecuteFunctions, + ILoadOptionsFunctions, +} from 'n8n-workflow'; +import { Readable } from 'stream'; +import { createMultipartForm, parseBodyData } from '../GenericFunctions'; +import { googleApiRequest, googleApiRequestAllItems } from './GenericFunctions'; +import { GoogleCloudStorageEnhanced } from './GoogleCloudStorageEnhanced.node'; + +jest.mock('../GenericFunctions'); +jest.mock('./GenericFunctions'); + +describe('GoogleCloudStorageEnhanced', () => { + const loadOptionsFunctions = mockDeep(); + const executeFunctions = mockDeep(); + const mockedCreateMultipartForm = jest.mocked(createMultipartForm); + const mockedGoogleApiRequest = jest.mocked(googleApiRequest); + const mockedGoogleApiRequestAllItems = jest.mocked(googleApiRequestAllItems); + const mockedParseBodyData = jest.mocked(parseBodyData); + + let googleCloudStorageEnhanced: GoogleCloudStorageEnhanced; + + beforeEach(() => { + googleCloudStorageEnhanced = new GoogleCloudStorageEnhanced(); + }); + + afterEach(() => { + mockClear(loadOptionsFunctions); + mockClear(executeFunctions); + mockClear(mockedCreateMultipartForm); + mockClear(mockedGoogleApiRequest); + mockClear(mockedGoogleApiRequestAllItems); + mockClear(mockedParseBodyData); + }); + + it('should be defined', () => { + expect(googleCloudStorageEnhanced).toBeDefined(); + }); + + it('should create a bucket', () => { + const bucket = { + kind: 'storage#bucket', + selfLink: 'https://www.googleapis.com/storage/v1/b/__bucket__', + id: '__bucket__', + name: '__bucket__', + projectNumber: '1234567890', + metageneration: '2', + location: 'EU', + storageClass: 'STANDARD', + etag: 'CAI=', + timeCreated: '2024-02-26T12:29:08.082Z', + updated: '2024-02-26T12:55:47.601Z', + iamConfiguration: { + bucketPolicyOnly: { + enabled: true, + lockedTime: '2024-05-26T12:29:08.082Z', + }, + uniformBucketLevelAccess: { + enabled: true, + lockedTime: '2024-05-26T12:29:08.082Z', + }, + publicAccessPrevention: 'enforced', + }, + locationType: 'multi-region', + rpo: 'DEFAULT', + }; + const jsonArray = [{ json: bucket }]; + const executionData = jsonArray.map(({ json }) => ({ + json, + pairedItem: { item: 0 }, + })); + + executeFunctions.getInputData.mockReturnValue([ + { + json: {}, + }, + ]); + + executeFunctions.getNodeParameter + .calledWith('resource', 0) + .mockReturnValue('bucket'); + executeFunctions.getNodeParameter + .calledWith('operation', 0) + .mockReturnValue('create'); + executeFunctions.getNodeParameter + .calledWith('bucketName', 0) + .mockReturnValue('__bucket__'); + executeFunctions.getNodeParameter + .calledWith('createBody', 0) + .mockReturnValue({}); + executeFunctions.getNodeParameter + .calledWith('projectId', 0) + .mockReturnValue('__project_id__'); + executeFunctions.getNodeParameter + .calledWith('enableObjectRetention', 0) + .mockReturnValue(true); + executeFunctions.getNodeParameter + .calledWith('createAcl', 0) + .mockReturnValue({}); + executeFunctions.getNodeParameter + .calledWith('projection', 0) + .mockReturnValue('noAcl'); + + mockedParseBodyData.mockReturnValue({}); + mockedGoogleApiRequest.mockResolvedValue(bucket); + + executeFunctions.helpers.returnJsonArray.mockReturnValue(jsonArray); + + executeFunctions.helpers.constructExecutionMetaData.mockReturnValue( + executionData, + ); + + expect( + googleCloudStorageEnhanced.execute.call(executeFunctions), + ).resolves.toEqual([executionData]); + + expect(mockedGoogleApiRequest).toHaveBeenCalledWith( + 'POST', + '/b', + { + name: '__bucket__', + }, + { + project: '__project_id__', + enableObjectRetention: true, + projection: 'noAcl', + }, + ); + }); + + it('should delete a bucket', () => { + const bucket = {}; + const jsonArray = [{ json: bucket }]; + const executionData = jsonArray.map(({ json }) => ({ + json, + pairedItem: { item: 0 }, + })); + + executeFunctions.getInputData.mockReturnValue([ + { + json: {}, + }, + ]); + + executeFunctions.getNodeParameter + .calledWith('resource', 0) + .mockReturnValue('bucket'); + executeFunctions.getNodeParameter + .calledWith('operation', 0) + .mockReturnValue('delete'); + executeFunctions.getNodeParameter + .calledWith('bucketName', 0) + .mockReturnValue('__bucket__'); + executeFunctions.getNodeParameter + .calledWith('getFilters', 0) + .mockReturnValue({}); + + mockedGoogleApiRequest.mockResolvedValue(bucket); + + executeFunctions.helpers.returnJsonArray.mockReturnValue(jsonArray); + + executeFunctions.helpers.constructExecutionMetaData.mockReturnValue( + executionData, + ); + + expect( + googleCloudStorageEnhanced.execute.call(executeFunctions), + ).resolves.toEqual([executionData]); + + expect(mockedGoogleApiRequest).toHaveBeenCalledWith( + 'DELETE', + '/b/__bucket__', + undefined, + {}, + ); + }); + + it('should get a bucket', () => { + const bucket = { + kind: 'storage#bucket', + selfLink: 'https://www.googleapis.com/storage/v1/b/__bucket__', + id: '__bucket__', + name: '__bucket__', + projectNumber: '1234567890', + metageneration: '2', + location: 'EU', + storageClass: 'STANDARD', + etag: 'CAI=', + timeCreated: '2024-02-26T12:29:08.082Z', + updated: '2024-02-26T12:55:47.601Z', + iamConfiguration: { + bucketPolicyOnly: { + enabled: true, + lockedTime: '2024-05-26T12:29:08.082Z', + }, + uniformBucketLevelAccess: { + enabled: true, + lockedTime: '2024-05-26T12:29:08.082Z', + }, + publicAccessPrevention: 'enforced', + }, + locationType: 'multi-region', + rpo: 'DEFAULT', + }; + const jsonArray = [{ json: bucket }]; + const executionData = jsonArray.map(({ json }) => ({ + json, + pairedItem: { item: 0 }, + })); + + executeFunctions.getInputData.mockReturnValue([ + { + json: {}, + }, + ]); + + executeFunctions.getNodeParameter + .calledWith('resource', 0) + .mockReturnValue('bucket'); + executeFunctions.getNodeParameter + .calledWith('operation', 0) + .mockReturnValue('get'); + executeFunctions.getNodeParameter + .calledWith('bucketName', 0) + .mockReturnValue('__bucket__'); + executeFunctions.getNodeParameter + .calledWith('getFilters', 0) + .mockReturnValue({}); + executeFunctions.getNodeParameter + .calledWith('projection', 0) + .mockReturnValue('noAcl'); + + mockedGoogleApiRequest.mockResolvedValue(bucket); + + executeFunctions.helpers.returnJsonArray.mockReturnValue(jsonArray); + + executeFunctions.helpers.constructExecutionMetaData.mockReturnValue( + executionData, + ); + + expect( + googleCloudStorageEnhanced.execute.call(executeFunctions), + ).resolves.toEqual([executionData]); + + expect(mockedGoogleApiRequest).toHaveBeenCalledWith( + 'GET', + '/b/__bucket__', + undefined, + { + projection: 'noAcl', + }, + ); + }); + + it('should get all buckets', () => { + const buckets = [ + { + kind: 'storage#bucket', + selfLink: 'https://www.googleapis.com/storage/v1/b/__bucket__', + id: '__bucket__', + name: '__bucket__', + projectNumber: '1234567890', + metageneration: '2', + location: 'EU', + storageClass: 'STANDARD', + etag: 'CAI=', + timeCreated: '2024-02-26T12:29:08.082Z', + updated: '2024-02-26T12:55:47.601Z', + iamConfiguration: { + bucketPolicyOnly: { + enabled: true, + lockedTime: '2024-05-26T12:29:08.082Z', + }, + uniformBucketLevelAccess: { + enabled: true, + lockedTime: '2024-05-26T12:29:08.082Z', + }, + publicAccessPrevention: 'enforced', + }, + locationType: 'multi-region', + rpo: 'DEFAULT', + }, + ]; + const jsonArray = buckets.map((json) => ({ json })); + const executionData = jsonArray.map(({ json }) => ({ + json, + pairedItem: { item: 0 }, + })); + + executeFunctions.getInputData.mockReturnValue([ + { + json: {}, + }, + ]); + + executeFunctions.getNodeParameter + .calledWith('resource', 0) + .mockReturnValue('bucket'); + executeFunctions.getNodeParameter + .calledWith('operation', 0) + .mockReturnValue('getAll'); + executeFunctions.getNodeParameter + .calledWith('returnAll', 0) + .mockReturnValue(true); + executeFunctions.getNodeParameter + .calledWith('projectId', 0) + .mockReturnValue('__project_id__'); + executeFunctions.getNodeParameter + .calledWith('prefix', 0) + .mockReturnValue(''); + executeFunctions.getNodeParameter + .calledWith('projection', 0) + .mockReturnValue('noAcl'); + + mockedGoogleApiRequestAllItems.mockResolvedValue(buckets); + + executeFunctions.helpers.returnJsonArray.mockReturnValue(jsonArray); + + executeFunctions.helpers.constructExecutionMetaData.mockReturnValue( + executionData, + ); + + expect( + googleCloudStorageEnhanced.execute.call(executeFunctions), + ).resolves.toEqual([executionData]); + + expect(mockedGoogleApiRequestAllItems).toHaveBeenCalledWith( + 'GET', + '/b', + undefined, + { + project: '__project_id__', + prefix: '', + projection: 'noAcl', + }, + ); + }); + + it('should get some buckets', () => { + const buckets = [ + { + kind: 'storage#bucket', + selfLink: 'https://www.googleapis.com/storage/v1/b/__bucket__', + id: '__bucket__', + name: '__bucket__', + projectNumber: '1234567890', + metageneration: '2', + location: 'EU', + storageClass: 'STANDARD', + etag: 'CAI=', + timeCreated: '2024-02-26T12:29:08.082Z', + updated: '2024-02-26T12:55:47.601Z', + iamConfiguration: { + bucketPolicyOnly: { + enabled: true, + lockedTime: '2024-05-26T12:29:08.082Z', + }, + uniformBucketLevelAccess: { + enabled: true, + lockedTime: '2024-05-26T12:29:08.082Z', + }, + publicAccessPrevention: 'enforced', + }, + locationType: 'multi-region', + rpo: 'DEFAULT', + }, + ]; + const jsonArray = buckets.map((json) => ({ json })); + const executionData = jsonArray.map(({ json }) => ({ + json, + pairedItem: { item: 0 }, + })); + + executeFunctions.getInputData.mockReturnValue([ + { + json: {}, + }, + ]); + + executeFunctions.getNodeParameter + .calledWith('resource', 0) + .mockReturnValue('bucket'); + executeFunctions.getNodeParameter + .calledWith('operation', 0) + .mockReturnValue('getAll'); + executeFunctions.getNodeParameter + .calledWith('returnAll', 0) + .mockReturnValue(false); + executeFunctions.getNodeParameter + .calledWith('projectId', 0) + .mockReturnValue('__project_id__'); + executeFunctions.getNodeParameter + .calledWith('prefix', 0) + .mockReturnValue(''); + executeFunctions.getNodeParameter + .calledWith('projection', 0) + .mockReturnValue('noAcl'); + + mockedGoogleApiRequestAllItems.mockResolvedValue(buckets); + + executeFunctions.helpers.returnJsonArray.mockReturnValue(jsonArray); + + executeFunctions.helpers.constructExecutionMetaData.mockReturnValue( + executionData, + ); + + expect( + googleCloudStorageEnhanced.execute.call(executeFunctions), + ).resolves.toEqual([executionData]); + + expect(mockedGoogleApiRequestAllItems).toHaveBeenCalledWith( + 'GET', + '/b', + undefined, + { + project: '__project_id__', + prefix: '', + projection: 'noAcl', + maxResults: 1000, + }, + ); + }); + + it('should get some buckets with full resonse', () => { + const buckets = [ + { + kind: 'storage#bucket', + selfLink: 'https://www.googleapis.com/storage/v1/b/__bucket__', + id: '__bucket__', + name: '__bucket__', + projectNumber: '1234567890', + metageneration: '2', + location: 'EU', + storageClass: 'STANDARD', + etag: 'CAI=', + timeCreated: '2024-02-26T12:29:08.082Z', + updated: '2024-02-26T12:55:47.601Z', + iamConfiguration: { + bucketPolicyOnly: { + enabled: true, + lockedTime: '2024-05-26T12:29:08.082Z', + }, + uniformBucketLevelAccess: { + enabled: true, + lockedTime: '2024-05-26T12:29:08.082Z', + }, + publicAccessPrevention: 'enforced', + }, + locationType: 'multi-region', + rpo: 'DEFAULT', + }, + ]; + const jsonArray = buckets.map((json) => ({ json })); + const executionData = jsonArray.map(({ json }) => ({ + json, + pairedItem: { item: 0 }, + })); + + executeFunctions.getInputData.mockReturnValue([ + { + json: {}, + }, + ]); + + executeFunctions.getNodeParameter + .calledWith('resource', 0) + .mockReturnValue('bucket'); + executeFunctions.getNodeParameter + .calledWith('operation', 0) + .mockReturnValue('getAll'); + executeFunctions.getNodeParameter + .calledWith('returnAll', 0) + .mockReturnValue(false); + executeFunctions.getNodeParameter + .calledWith('projectId', 0) + .mockReturnValue('__project_id__'); + executeFunctions.getNodeParameter + .calledWith('prefix', 0) + .mockReturnValue(''); + executeFunctions.getNodeParameter + .calledWith('projection', 0) + .mockReturnValue('full'); + + mockedGoogleApiRequestAllItems.mockResolvedValue(buckets); + + executeFunctions.helpers.returnJsonArray.mockReturnValue(jsonArray); + + executeFunctions.helpers.constructExecutionMetaData.mockReturnValue( + executionData, + ); + + expect( + googleCloudStorageEnhanced.execute.call(executeFunctions), + ).resolves.toEqual([executionData]); + + expect(mockedGoogleApiRequestAllItems).toHaveBeenCalledWith( + 'GET', + '/b', + undefined, + { + project: '__project_id__', + prefix: '', + projection: 'full', + maxResults: 200, + }, + ); + }); + + it('should update a bucket', () => { + const bucket = { + kind: 'storage#bucket', + selfLink: 'https://www.googleapis.com/storage/v1/b/__bucket__', + id: '__bucket__', + name: '__bucket__', + projectNumber: '1234567890', + metageneration: '2', + location: 'EU', + storageClass: 'STANDARD', + etag: 'CAI=', + timeCreated: '2024-02-26T12:29:08.082Z', + updated: '2024-02-26T12:55:47.601Z', + iamConfiguration: { + bucketPolicyOnly: { + enabled: true, + lockedTime: '2024-05-26T12:29:08.082Z', + }, + uniformBucketLevelAccess: { + enabled: true, + lockedTime: '2024-05-26T12:29:08.082Z', + }, + publicAccessPrevention: 'enforced', + }, + locationType: 'multi-region', + rpo: 'DEFAULT', + }; + const jsonArray = [{ json: bucket }]; + const executionData = jsonArray.map(({ json }) => ({ + json, + pairedItem: { item: 0 }, + })); + + executeFunctions.getInputData.mockReturnValue([ + { + json: {}, + }, + ]); + + executeFunctions.getNodeParameter + .calledWith('resource', 0) + .mockReturnValue('bucket'); + executeFunctions.getNodeParameter + .calledWith('operation', 0) + .mockReturnValue('update'); + executeFunctions.getNodeParameter + .calledWith('bucketName', 0) + .mockReturnValue('__bucket__'); + executeFunctions.getNodeParameter + .calledWith('createBody', 0) + .mockReturnValue({}); + executeFunctions.getNodeParameter + .calledWith('getFilters', 0) + .mockReturnValue({}); + executeFunctions.getNodeParameter + .calledWith('createAcl', 0) + .mockReturnValue({}); + executeFunctions.getNodeParameter + .calledWith('projection', 0) + .mockReturnValue('noAcl'); + + mockedParseBodyData.mockReturnValue({}); + mockedGoogleApiRequest.mockResolvedValue(bucket); + + executeFunctions.helpers.returnJsonArray.mockReturnValue(jsonArray); + + executeFunctions.helpers.constructExecutionMetaData.mockReturnValue( + executionData, + ); + + expect( + googleCloudStorageEnhanced.execute.call(executeFunctions), + ).resolves.toEqual([executionData]); + + expect(mockedGoogleApiRequest).toHaveBeenCalledWith( + 'PATCH', + '/b/__bucket__', + {}, + { + projection: 'noAcl', + }, + ); + }); + + it('should create an object from binary stream without mime type', () => { + const object = { + kind: 'storage#object', + id: '__bucket__/__object__/1234567890', + selfLink: + 'https://www.googleapis.com/storage/v1/b/__bucket__/o/__object__', + mediaLink: + 'https://storage.googleapis.com/download/storage/v1/b/__bucket__/o/__object__?generation=1234567890&alt=media', + name: '__object__', + bucket: '__bucket__', + generation: '1234567890', + metageneration: '1', + contentType: 'application/json', + storageClass: 'STANDARD', + size: '15', + md5Hash: 'govO+HY8G8YW4loGvkuQ/w==', + crc32c: 'JA/WgA==', + etag: 'CMHqspap2oQDEAE=', + timeCreated: '2024-03-04T09:44:35.645Z', + updated: '2024-03-04T09:44:35.645Z', + timeStorageClassUpdated: '2024-03-04T09:44:35.645Z', + }; + const jsonArray = [{ json: object }]; + const executionData = jsonArray.map(({ json }) => ({ + json, + pairedItem: { item: 0 }, + })); + + executeFunctions.getInputData.mockReturnValue([ + { + json: {}, + }, + ]); + + executeFunctions.getNodeParameter + .calledWith('resource', 0) + .mockReturnValue('object'); + executeFunctions.getNodeParameter + .calledWith('operation', 0) + .mockReturnValue('create'); + executeFunctions.getNodeParameter + .calledWith('bucketName', 0) + .mockReturnValue('__bucket__'); + executeFunctions.getNodeParameter + .calledWith('objectName', 0) + .mockReturnValue('__object__'); + executeFunctions.getNodeParameter + .calledWith('createData', 0) + .mockReturnValue({}); + executeFunctions.getNodeParameter + .calledWith('createQuery', 0) + .mockReturnValue({}); + executeFunctions.getNodeParameter + .calledWith('updateProjection', 0) + .mockReturnValue('noAcl'); + executeFunctions.getNodeParameter + .calledWith('encryptionHeaders', 0) + .mockReturnValue({}); + executeFunctions.getNodeParameter + .calledWith('createFromBinary', 0) + .mockReturnValue(true); + executeFunctions.getNodeParameter + .calledWith('createBinaryPropertyName', 0) + .mockReturnValue('data'); + executeFunctions.helpers.assertBinaryData.mockReturnValue( + mock({ + id: '123', + }), + ); + executeFunctions.helpers.getBinaryStream.mockResolvedValue( + mock(), + ); + executeFunctions.helpers.getBinaryMetadata.mockResolvedValue({ + fileSize: 0, + }); + + const body = mock({ + getLengthSync: jest.fn().mockReturnValue(0), + getBoundary: jest.fn().mockReturnValue('__boundary__'), + }); + + mockedCreateMultipartForm.mockReturnValue(body); + mockedParseBodyData.mockReturnValue({}); + mockedGoogleApiRequest.mockResolvedValue(object); + + executeFunctions.helpers.returnJsonArray.mockReturnValue(jsonArray); + + executeFunctions.helpers.constructExecutionMetaData.mockReturnValue( + executionData, + ); + + expect( + googleCloudStorageEnhanced.execute.call(executeFunctions), + ).resolves.toEqual([executionData]); + }); + + it('should create an object from binary stream with mime type', () => { + const object = { + kind: 'storage#object', + id: '__bucket__/__object__/1234567890', + selfLink: + 'https://www.googleapis.com/storage/v1/b/__bucket__/o/__object__', + mediaLink: + 'https://storage.googleapis.com/download/storage/v1/b/__bucket__/o/__object__?generation=1234567890&alt=media', + name: '__object__', + bucket: '__bucket__', + generation: '1234567890', + metageneration: '1', + contentType: 'application/json', + storageClass: 'STANDARD', + size: '15', + md5Hash: 'govO+HY8G8YW4loGvkuQ/w==', + crc32c: 'JA/WgA==', + etag: 'CMHqspap2oQDEAE=', + timeCreated: '2024-03-04T09:44:35.645Z', + updated: '2024-03-04T09:44:35.645Z', + timeStorageClassUpdated: '2024-03-04T09:44:35.645Z', + }; + const jsonArray = [{ json: object }]; + const executionData = jsonArray.map(({ json }) => ({ + json, + pairedItem: { item: 0 }, + })); + + executeFunctions.getInputData.mockReturnValue([ + { + json: {}, + }, + ]); + + executeFunctions.getNodeParameter + .calledWith('resource', 0) + .mockReturnValue('object'); + executeFunctions.getNodeParameter + .calledWith('operation', 0) + .mockReturnValue('create'); + executeFunctions.getNodeParameter + .calledWith('bucketName', 0) + .mockReturnValue('__bucket__'); + executeFunctions.getNodeParameter + .calledWith('objectName', 0) + .mockReturnValue('__object__'); + executeFunctions.getNodeParameter + .calledWith('createData', 0) + .mockReturnValue({}); + executeFunctions.getNodeParameter + .calledWith('createQuery', 0) + .mockReturnValue({}); + executeFunctions.getNodeParameter + .calledWith('updateProjection', 0) + .mockReturnValue('noAcl'); + executeFunctions.getNodeParameter + .calledWith('encryptionHeaders', 0) + .mockReturnValue({}); + executeFunctions.getNodeParameter + .calledWith('createFromBinary', 0) + .mockReturnValue(true); + executeFunctions.getNodeParameter + .calledWith('createBinaryPropertyName', 0) + .mockReturnValue('data'); + executeFunctions.helpers.assertBinaryData.mockReturnValue( + mock({ + id: '123', + }), + ); + executeFunctions.helpers.getBinaryStream.mockResolvedValue( + mock(), + ); + executeFunctions.helpers.getBinaryMetadata.mockResolvedValue({ + mimeType: 'application/octet-stream', + fileSize: 0, + }); + + const body = mock({ + getLengthSync: jest.fn().mockReturnValue(0), + getBoundary: jest.fn().mockReturnValue('__boundary__'), + }); + + mockedCreateMultipartForm.mockReturnValue(body); + mockedParseBodyData.mockReturnValue({}); + mockedGoogleApiRequest.mockResolvedValue(object); + + executeFunctions.helpers.returnJsonArray.mockReturnValue(jsonArray); + + executeFunctions.helpers.constructExecutionMetaData.mockReturnValue( + executionData, + ); + + expect( + googleCloudStorageEnhanced.execute.call(executeFunctions), + ).resolves.toEqual([executionData]); + }); + + it('should create an object from binary data', () => { + const object = { + kind: 'storage#object', + id: '__bucket__/__object__/1234567890', + selfLink: + 'https://www.googleapis.com/storage/v1/b/__bucket__/o/__object__', + mediaLink: + 'https://storage.googleapis.com/download/storage/v1/b/__bucket__/o/__object__?generation=1234567890&alt=media', + name: '__object__', + bucket: '__bucket__', + generation: '1234567890', + metageneration: '1', + contentType: 'application/json', + storageClass: 'STANDARD', + size: '15', + md5Hash: 'govO+HY8G8YW4loGvkuQ/w==', + crc32c: 'JA/WgA==', + etag: 'CMHqspap2oQDEAE=', + timeCreated: '2024-03-04T09:44:35.645Z', + updated: '2024-03-04T09:44:35.645Z', + timeStorageClassUpdated: '2024-03-04T09:44:35.645Z', + }; + const jsonArray = [{ json: object }]; + const executionData = jsonArray.map(({ json }) => ({ + json, + pairedItem: { item: 0 }, + })); + + executeFunctions.getInputData.mockReturnValue([ + { + json: {}, + }, + ]); + + executeFunctions.getNodeParameter + .calledWith('resource', 0) + .mockReturnValue('object'); + executeFunctions.getNodeParameter + .calledWith('operation', 0) + .mockReturnValue('create'); + executeFunctions.getNodeParameter + .calledWith('bucketName', 0) + .mockReturnValue('__bucket__'); + executeFunctions.getNodeParameter + .calledWith('objectName', 0) + .mockReturnValue('__object__'); + executeFunctions.getNodeParameter + .calledWith('createData', 0) + .mockReturnValue({}); + executeFunctions.getNodeParameter + .calledWith('createQuery', 0) + .mockReturnValue({}); + executeFunctions.getNodeParameter + .calledWith('updateProjection', 0) + .mockReturnValue('noAcl'); + executeFunctions.getNodeParameter + .calledWith('encryptionHeaders', 0) + .mockReturnValue({}); + executeFunctions.getNodeParameter + .calledWith('createFromBinary', 0) + .mockReturnValue(true); + executeFunctions.helpers.assertBinaryData.mockReturnValue( + mock({ + id: undefined, + data: '', + mimeType: 'application/octet-stream', + }), + ); + + const body = mock({ + getLengthSync: jest.fn().mockReturnValue(0), + getBoundary: jest.fn().mockReturnValue('__boundary__'), + }); + + mockedCreateMultipartForm.mockReturnValue(body); + mockedParseBodyData.mockReturnValue({}); + mockedGoogleApiRequest.mockResolvedValue(object); + + executeFunctions.helpers.returnJsonArray.mockReturnValue(jsonArray); + + executeFunctions.helpers.constructExecutionMetaData.mockReturnValue( + executionData, + ); + + expect( + googleCloudStorageEnhanced.execute.call(executeFunctions), + ).resolves.toEqual([executionData]); + + expect(mockedGoogleApiRequest).toHaveBeenCalledWith( + 'POST', + expect.any(URL), + body, + { + name: '__object__', + uploadType: 'multipart', + projection: 'noAcl', + }, + { + 'Content-Length': 0, + 'Content-Type': `multipart/related; boundary=__boundary__`, + }, + ); + }); + + it('should create an object from string data', () => { + const object = { + kind: 'storage#object', + id: '__bucket__/__object__/1234567890', + selfLink: + 'https://www.googleapis.com/storage/v1/b/__bucket__/o/__object__', + mediaLink: + 'https://storage.googleapis.com/download/storage/v1/b/__bucket__/o/__object__?generation=1234567890&alt=media', + name: '__object__', + bucket: '__bucket__', + generation: '1234567890', + metageneration: '1', + contentType: 'application/json', + storageClass: 'STANDARD', + size: '15', + md5Hash: 'govO+HY8G8YW4loGvkuQ/w==', + crc32c: 'JA/WgA==', + etag: 'CMHqspap2oQDEAE=', + timeCreated: '2024-03-04T09:44:35.645Z', + updated: '2024-03-04T09:44:35.645Z', + timeStorageClassUpdated: '2024-03-04T09:44:35.645Z', + }; + const jsonArray = [{ json: object }]; + const executionData = jsonArray.map(({ json }) => ({ + json, + pairedItem: { item: 0 }, + })); + + executeFunctions.getInputData.mockReturnValue([ + { + json: {}, + }, + ]); + + executeFunctions.getNodeParameter + .calledWith('resource', 0) + .mockReturnValue('object'); + executeFunctions.getNodeParameter + .calledWith('operation', 0) + .mockReturnValue('create'); + executeFunctions.getNodeParameter + .calledWith('bucketName', 0) + .mockReturnValue('__bucket__'); + executeFunctions.getNodeParameter + .calledWith('objectName', 0) + .mockReturnValue('__object__'); + executeFunctions.getNodeParameter + .calledWith('createData', 0) + .mockReturnValue({}); + executeFunctions.getNodeParameter + .calledWith('createQuery', 0) + .mockReturnValue({}); + executeFunctions.getNodeParameter + .calledWith('updateProjection', 0) + .mockReturnValue('noAcl'); + executeFunctions.getNodeParameter + .calledWith('encryptionHeaders', 0) + .mockReturnValue({}); + executeFunctions.getNodeParameter + .calledWith('createFromBinary', 0) + .mockReturnValue(false); + executeFunctions.getNodeParameter + .calledWith('createContent', 0) + .mockReturnValue(''); + + const body = mock({ + getLengthSync: jest.fn().mockReturnValue(0), + getBoundary: jest.fn().mockReturnValue('__boundary__'), + }); + + mockedCreateMultipartForm.mockReturnValue(body); + mockedParseBodyData.mockReturnValue({}); + mockedGoogleApiRequest.mockResolvedValue(object); + + executeFunctions.helpers.returnJsonArray.mockReturnValue(jsonArray); + + executeFunctions.helpers.constructExecutionMetaData.mockReturnValue( + executionData, + ); + + expect( + googleCloudStorageEnhanced.execute.call(executeFunctions), + ).resolves.toEqual([executionData]); + + expect(mockedGoogleApiRequest).toHaveBeenCalledWith( + 'POST', + expect.any(URL), + body, + { + name: '__object__', + uploadType: 'multipart', + projection: 'noAcl', + }, + { + 'Content-Length': 0, + 'Content-Type': `multipart/related; boundary=__boundary__`, + }, + ); + }); + + it('should delete an object', () => { + const object = {}; + const jsonArray = [{ json: object }]; + const executionData = jsonArray.map(({ json }) => ({ + json, + pairedItem: { item: 0 }, + })); + + executeFunctions.getInputData.mockReturnValue([ + { + json: {}, + }, + ]); + + executeFunctions.getNodeParameter + .calledWith('resource', 0) + .mockReturnValue('object'); + executeFunctions.getNodeParameter + .calledWith('operation', 0) + .mockReturnValue('delete'); + executeFunctions.getNodeParameter + .calledWith('bucketName', 0) + .mockReturnValue('__bucket__'); + executeFunctions.getNodeParameter + .calledWith('objectName', 0) + .mockReturnValue('__object__'); + executeFunctions.getNodeParameter + .calledWith('getParameters', 0) + .mockReturnValue({}); + + mockedGoogleApiRequest.mockResolvedValue(object); + + executeFunctions.helpers.returnJsonArray.mockReturnValue(jsonArray); + + executeFunctions.helpers.constructExecutionMetaData.mockReturnValue( + executionData, + ); + + expect( + googleCloudStorageEnhanced.execute.call(executeFunctions), + ).resolves.toEqual([executionData]); + + expect(mockedGoogleApiRequest).toHaveBeenCalledWith( + 'DELETE', + '/b/__bucket__/o/__object__', + undefined, + {}, + ); + }); + + it('should get an object', () => { + const object = { + kind: 'storage#object', + id: '__bucket__/__object__/1234567890', + selfLink: + 'https://www.googleapis.com/storage/v1/b/__bucket__/o/__object__', + mediaLink: + 'https://storage.googleapis.com/download/storage/v1/b/__bucket__/o/__object__?generation=1234567890&alt=media', + name: '__object__', + bucket: '__bucket__', + generation: '1234567890', + metageneration: '1', + contentType: 'application/json', + storageClass: 'STANDARD', + size: '15', + md5Hash: 'govO+HY8G8YW4loGvkuQ/w==', + crc32c: 'JA/WgA==', + etag: 'CMHqspap2oQDEAE=', + timeCreated: '2024-03-04T09:44:35.645Z', + updated: '2024-03-04T09:44:35.645Z', + timeStorageClassUpdated: '2024-03-04T09:44:35.645Z', + }; + const jsonArray = [{ json: object }]; + const executionData = jsonArray.map(({ json }) => ({ + json, + pairedItem: { item: 0 }, + })); + + executeFunctions.getInputData.mockReturnValue([ + { + json: {}, + }, + ]); + + executeFunctions.getNodeParameter + .calledWith('resource', 0) + .mockReturnValue('object'); + executeFunctions.getNodeParameter + .calledWith('operation', 0) + .mockReturnValue('get'); + executeFunctions.getNodeParameter + .calledWith('bucketName', 0) + .mockReturnValue('__bucket__'); + executeFunctions.getNodeParameter + .calledWith('objectName', 0) + .mockReturnValue('__object__'); + executeFunctions.getNodeParameter + .calledWith('getParameters', 0) + .mockReturnValue({}); + executeFunctions.getNodeParameter + .calledWith('projection', 0) + .mockReturnValue('noAcl'); + executeFunctions.getNodeParameter + .calledWith('encryptionHeaders', 0) + .mockReturnValue({}); + + mockedGoogleApiRequest.mockResolvedValue(object); + + executeFunctions.helpers.returnJsonArray.mockReturnValue(jsonArray); + + executeFunctions.helpers.constructExecutionMetaData.mockReturnValue( + executionData, + ); + + expect( + googleCloudStorageEnhanced.execute.call(executeFunctions), + ).resolves.toEqual([executionData]); + + expect(mockedGoogleApiRequest).toHaveBeenCalledWith( + 'GET', + '/b/__bucket__/o/__object__', + undefined, + { + projection: 'noAcl', + }, + {}, + ); + }); + + it('should get all objects', () => { + const objects = [ + { + kind: 'storage#object', + id: '__bucket__/__object__/1234567890', + selfLink: + 'https://www.googleapis.com/storage/v1/b/__bucket__/o/__object__', + mediaLink: + 'https://storage.googleapis.com/download/storage/v1/b/__bucket__/o/__object__?generation=1234567890&alt=media', + name: '__object__', + bucket: '__bucket__', + generation: '1234567890', + metageneration: '1', + contentType: 'application/json', + storageClass: 'STANDARD', + size: '15', + md5Hash: 'govO+HY8G8YW4loGvkuQ/w==', + crc32c: 'JA/WgA==', + etag: 'CMHqspap2oQDEAE=', + timeCreated: '2024-03-04T09:44:35.645Z', + updated: '2024-03-04T09:44:35.645Z', + timeStorageClassUpdated: '2024-03-04T09:44:35.645Z', + }, + ]; + const jsonArray = objects.map((json) => ({ json })); + const executionData = jsonArray.map(({ json }) => ({ + json, + pairedItem: { item: 0 }, + })); + + executeFunctions.getInputData.mockReturnValue([ + { + json: {}, + }, + ]); + + executeFunctions.getNodeParameter + .calledWith('resource', 0) + .mockReturnValue('object'); + executeFunctions.getNodeParameter + .calledWith('operation', 0) + .mockReturnValue('getAll'); + executeFunctions.getNodeParameter + .calledWith('bucketName', 0) + .mockReturnValue('__bucket__'); + executeFunctions.getNodeParameter + .calledWith('listFilters', 0) + .mockReturnValue({}); + executeFunctions.getNodeParameter + .calledWith('projection', 0) + .mockReturnValue('noAcl'); + executeFunctions.getNodeParameter + .calledWith('encryptionHeaders', 0) + .mockReturnValue({}); + executeFunctions.getNodeParameter + .calledWith('returnAll', 0) + .mockReturnValue(true); + executeFunctions.getNodeParameter + .calledWith('maxResults', 0) + .mockReturnValue(100); + + mockedGoogleApiRequestAllItems.mockResolvedValue(objects); + + executeFunctions.helpers.returnJsonArray.mockReturnValue(jsonArray); + + executeFunctions.helpers.constructExecutionMetaData.mockReturnValue( + executionData, + ); + + expect( + googleCloudStorageEnhanced.execute.call(executeFunctions), + ).resolves.toEqual([executionData]); + + expect(mockedGoogleApiRequestAllItems).toHaveBeenCalledWith( + 'GET', + '/b/__bucket__/o', + undefined, + { + projection: 'noAcl', + }, + ); + }); + + it('should get some objects', () => { + const objects = [ + { + kind: 'storage#object', + id: '__bucket__/__object__/1234567890', + selfLink: + 'https://www.googleapis.com/storage/v1/b/__bucket__/o/__object__', + mediaLink: + 'https://storage.googleapis.com/download/storage/v1/b/__bucket__/o/__object__?generation=1234567890&alt=media', + name: '__object__', + bucket: '__bucket__', + generation: '1234567890', + metageneration: '1', + contentType: 'application/json', + storageClass: 'STANDARD', + size: '15', + md5Hash: 'govO+HY8G8YW4loGvkuQ/w==', + crc32c: 'JA/WgA==', + etag: 'CMHqspap2oQDEAE=', + timeCreated: '2024-03-04T09:44:35.645Z', + updated: '2024-03-04T09:44:35.645Z', + timeStorageClassUpdated: '2024-03-04T09:44:35.645Z', + }, + ]; + const jsonArray = objects.map((json) => ({ json })); + const executionData = jsonArray.map(({ json }) => ({ + json, + pairedItem: { item: 0 }, + })); + + executeFunctions.getInputData.mockReturnValue([ + { + json: {}, + }, + ]); + + executeFunctions.getNodeParameter + .calledWith('resource', 0) + .mockReturnValue('object'); + executeFunctions.getNodeParameter + .calledWith('operation', 0) + .mockReturnValue('getAll'); + executeFunctions.getNodeParameter + .calledWith('bucketName', 0) + .mockReturnValue('__bucket__'); + executeFunctions.getNodeParameter + .calledWith('listFilters', 0) + .mockReturnValue({}); + executeFunctions.getNodeParameter + .calledWith('projection', 0) + .mockReturnValue('noAcl'); + executeFunctions.getNodeParameter + .calledWith('encryptionHeaders', 0) + .mockReturnValue({}); + executeFunctions.getNodeParameter + .calledWith('returnAll', 0) + .mockReturnValue(false); + executeFunctions.getNodeParameter + .calledWith('maxResults', 0) + .mockReturnValue(100); + + mockedGoogleApiRequestAllItems.mockResolvedValue(objects); + + executeFunctions.helpers.returnJsonArray.mockReturnValue(jsonArray); + + executeFunctions.helpers.constructExecutionMetaData.mockReturnValue( + executionData, + ); + + expect( + googleCloudStorageEnhanced.execute.call(executeFunctions), + ).resolves.toEqual([executionData]); + + expect(mockedGoogleApiRequestAllItems).toHaveBeenCalledWith( + 'GET', + '/b/__bucket__/o', + undefined, + { + projection: 'noAcl', + maxResults: 100, + }, + ); + }); + it('should update an object', () => { + const object = { + kind: 'storage#object', + id: '__bucket__/__object__/1234567890', + selfLink: + 'https://www.googleapis.com/storage/v1/b/__bucket__/o/__object__', + mediaLink: + 'https://storage.googleapis.com/download/storage/v1/b/__bucket__/o/__object__?generation=1234567890&alt=media', + name: '__object__', + bucket: '__bucket__', + generation: '1234567890', + metageneration: '1', + contentType: 'application/json', + storageClass: 'STANDARD', + size: '15', + md5Hash: 'govO+HY8G8YW4loGvkuQ/w==', + crc32c: 'JA/WgA==', + etag: 'CMHqspap2oQDEAE=', + timeCreated: '2024-03-04T09:44:35.645Z', + updated: '2024-03-04T09:44:35.645Z', + timeStorageClassUpdated: '2024-03-04T09:44:35.645Z', + }; + const jsonArray = [{ json: object }]; + const executionData = jsonArray.map(({ json }) => ({ + json, + pairedItem: { item: 0 }, + })); + + executeFunctions.getInputData.mockReturnValue([ + { + json: {}, + }, + ]); + + executeFunctions.getNodeParameter + .calledWith('resource', 0) + .mockReturnValue('object'); + executeFunctions.getNodeParameter + .calledWith('operation', 0) + .mockReturnValue('update'); + executeFunctions.getNodeParameter + .calledWith('bucketName', 0) + .mockReturnValue('__bucket__'); + executeFunctions.getNodeParameter + .calledWith('objectName', 0) + .mockReturnValue('__object__'); + executeFunctions.getNodeParameter + .calledWith('updateData', 0) + .mockReturnValue({}); + executeFunctions.getNodeParameter + .calledWith('metagenAndAclQuery', 0) + .mockReturnValue({}); + executeFunctions.getNodeParameter + .calledWith('updateProjection', 0) + .mockReturnValue('noAcl'); + executeFunctions.getNodeParameter + .calledWith('encryptionHeaders', 0) + .mockReturnValue({}); + + mockedParseBodyData.mockReturnValue({}); + mockedGoogleApiRequest.mockResolvedValue(object); + + executeFunctions.helpers.returnJsonArray.mockReturnValue(jsonArray); + + executeFunctions.helpers.constructExecutionMetaData.mockReturnValue( + executionData, + ); + + expect( + googleCloudStorageEnhanced.execute.call(executeFunctions), + ).resolves.toEqual([executionData]); + + expect(mockedGoogleApiRequest).toHaveBeenCalledWith( + 'PATCH', + '/b/__bucket__/o/__object__', + {}, + { + projection: 'noAcl', + }, + {}, + ); + }); + + it('should return an error', async () => { + const error = new Error('__error_message__'); + + executeFunctions.getInputData.mockReturnValue([ + { + json: {}, + }, + ]); + + executeFunctions.getNodeParameter + .calledWith('resource', 0) + .mockReturnValue('bucket'); + executeFunctions.getNodeParameter + .calledWith('operation', 0) + .mockReturnValue('get'); + executeFunctions.getNodeParameter + .calledWith('bucketName', 0) + .mockReturnValue('__bucket__'); + executeFunctions.getNodeParameter + .calledWith('getFilters', 0) + .mockReturnValue({}); + executeFunctions.getNodeParameter + .calledWith('projection', 0) + .mockReturnValue('noAcl'); + + jest.mocked(googleApiRequest).mockRejectedValue(error); + + executeFunctions.continueOnFail.mockReturnValue(true); + + expect( + googleCloudStorageEnhanced.execute.call(executeFunctions), + ).resolves.toEqual([[{ error: '__error_message__', json: {} }]]); + + expect(googleApiRequest).toHaveBeenCalledWith( + 'GET', + `/b/__bucket__`, + undefined, + { projection: 'noAcl' }, + ); + }); + + it('should throw an error', async () => { + const error = new Error('__error_message__'); + + executeFunctions.getInputData.mockReturnValue([ + { + json: {}, + }, + ]); + + executeFunctions.getNodeParameter + .calledWith('resource', 0) + .mockReturnValue('bucket'); + executeFunctions.getNodeParameter + .calledWith('operation', 0) + .mockReturnValue('get'); + executeFunctions.getNodeParameter + .calledWith('bucketName', 0) + .mockReturnValue('__bucket__'); + executeFunctions.getNodeParameter + .calledWith('getFilters', 0) + .mockReturnValue({}); + executeFunctions.getNodeParameter + .calledWith('projection', 0) + .mockReturnValue('noAcl'); + + jest.mocked(googleApiRequest).mockRejectedValue(error); + + executeFunctions.continueOnFail.mockReturnValue(false); + + expect( + googleCloudStorageEnhanced.execute.call(executeFunctions), + ).rejects.toEqual(error); + + expect(googleApiRequest).toHaveBeenCalledWith( + 'GET', + `/b/__bucket__`, + undefined, + { projection: 'noAcl' }, + ); + }); +}); diff --git a/nodes/google-enhanced/src/nodes/GoogleCloudStorageEnhanced/GoogleCloudStorageEnhanced.node.ts b/nodes/google-enhanced/src/nodes/GoogleCloudStorageEnhanced/GoogleCloudStorageEnhanced.node.ts new file mode 100644 index 00000000..a38fde05 --- /dev/null +++ b/nodes/google-enhanced/src/nodes/GoogleCloudStorageEnhanced/GoogleCloudStorageEnhanced.node.ts @@ -0,0 +1,547 @@ +import { + bucketFields, + bucketOperations, +} from 'n8n-nodes-base/dist/nodes/Google/CloudStorage/BucketDescription'; +import { + objectFields, + objectOperations, +} from 'n8n-nodes-base/dist/nodes/Google/CloudStorage/ObjectDescription'; +import { + BINARY_ENCODING, + type IDataObject, + type IExecuteFunctions, + type INodeExecutionData, + type INodeType, + type INodeTypeDescription, +} from 'n8n-workflow'; +import type { Readable } from 'stream'; +import { createMultipartForm, parseBodyData } from '../GenericFunctions'; +import { googleApiRequest, googleApiRequestAllItems } from './GenericFunctions'; + +export class GoogleCloudStorageEnhanced implements INodeType { + description: INodeTypeDescription = { + displayName: 'Google Cloud Storage Enhanced', + name: 'googleCloudStorageEnhanced', + icon: 'file:googleCloudStorageEnhanced.svg', + group: ['transform'], + version: 1, + subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + description: 'Use the Google Cloud Storage API', + defaults: { + name: 'Google Cloud Storage Enhanced', + }, + inputs: ['main'], + outputs: ['main'], + credentials: [ + { + name: 'googleApi', + required: true, + displayOptions: { + show: { + authentication: ['serviceAccount'], + }, + }, + }, + { + name: 'googleCloudStorageOAuth2Api', + required: true, + displayOptions: { + show: { + authentication: ['oAuth2'], + }, + }, + }, + ], + properties: [ + { + displayName: 'Authentication', + name: 'authentication', + type: 'options', + options: [ + { + name: 'Service Account', + value: 'serviceAccount', + }, + { + name: 'OAuth2', + value: 'oAuth2', + }, + ], + default: 'serviceAccount', + }, + { + displayName: 'Resource', + name: 'resource', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Bucket', + value: 'bucket', + }, + { + name: 'Object', + value: 'object', + }, + ], + default: 'bucket', + }, + + // BUCKET + ...bucketOperations, + ...bucketFields, + { + displayName: 'Enable Object Retention', + name: 'enableObjectRetention', + type: 'boolean', + displayOptions: { + show: { + resource: ['bucket'], + operation: ['create'], + }, + }, + default: false, + description: + 'Whether to permanently enable object retention for this bucket', + }, + + // OBJECT + ...objectOperations, + ...objectFields, + ], + }; + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + + const returnData: INodeExecutionData[] = []; + + let responseData: IDataObject | IDataObject[] = {}; + + const resource = this.getNodeParameter('resource', 0); + + const operation = this.getNodeParameter('operation', 0); + + for (let item = 0; item < items.length; item++) { + try { + if (resource === 'bucket') { + const bodyDataFields = [ + 'acl', + 'billing', + 'cors', + 'customPlacementConfig', + 'dataLocations', + 'defaultObjectAcl', + 'encryption', + 'iamConfiguration', + 'labels', + 'lifecycle', + 'logging', + 'retentionPolicy', + 'versioning', + 'website', + ]; + + if (operation === 'create') { + const bucketName = this.getNodeParameter( + 'bucketName', + item, + ) as string; + const createData = this.getNodeParameter( + 'createBody', + item, + ) as IDataObject; + const project = this.getNodeParameter('projectId', item) as string; + const enableObjectRetention = this.getNodeParameter( + 'enableObjectRetention', + item, + ) as boolean; + const createAcl = this.getNodeParameter( + 'createAcl', + item, + ) as IDataObject; + const projection = this.getNodeParameter( + 'projection', + item, + ) as string; + + responseData = await googleApiRequest.call( + this, + 'POST', + '/b', + { + name: bucketName, + ...parseBodyData(createData, bodyDataFields), + }, + { + project, + enableObjectRetention, + ...createAcl, + projection, + }, + ); + } + + if (operation === 'delete') { + const bucketName = this.getNodeParameter( + 'bucketName', + item, + ) as string; + const deleteFilters = this.getNodeParameter( + 'getFilters', + item, + ) as IDataObject; + + responseData = await googleApiRequest.call( + this, + 'DELETE', + `/b/${bucketName}`, + undefined, + deleteFilters, + ); + } + + if (operation === 'get') { + const bucketName = this.getNodeParameter( + 'bucketName', + item, + ) as string; + const getFilters = this.getNodeParameter( + 'getFilters', + item, + ) as IDataObject; + const projection = this.getNodeParameter( + 'projection', + item, + ) as string; + + responseData = await googleApiRequest.call( + this, + 'GET', + `/b/${bucketName}`, + undefined, + { + ...getFilters, + projection, + }, + ); + } + + if (operation === 'getAll') { + const returnAll = this.getNodeParameter( + 'returnAll', + item, + ) as boolean; + const project = this.getNodeParameter('projectId', item) as string; + const prefix = this.getNodeParameter('prefix', item) as string; + const projection = this.getNodeParameter( + 'projection', + item, + ) as string; + + responseData = await googleApiRequestAllItems.call( + this, + 'GET', + '/b', + undefined, + { + project, + prefix, + projection, + ...(!returnAll && { + maxResults: projection === 'noAcl' ? 1000 : 200, + }), + }, + ); + } + + if (operation === 'update') { + const bucketName = this.getNodeParameter( + 'bucketName', + item, + ) as string; + const updateData = this.getNodeParameter( + 'createBody', + item, + ) as IDataObject; + const updateFilters = this.getNodeParameter( + 'getFilters', + item, + ) as IDataObject; + const createAcl = this.getNodeParameter( + 'createAcl', + item, + ) as IDataObject; + const projection = this.getNodeParameter( + 'projection', + item, + ) as string; + + responseData = await googleApiRequest.call( + this, + 'PATCH', + `/b/${bucketName}`, + parseBodyData(updateData, bodyDataFields), + { + ...updateFilters, + ...createAcl, + projection, + }, + ); + } + } + + if (resource === 'object') { + const bodyDataFields = ['acl', 'metadata']; + + if (operation === 'create') { + const bucketName = this.getNodeParameter( + 'bucketName', + item, + ) as string; + const objectName = this.getNodeParameter( + 'objectName', + item, + ) as string; + const createData = this.getNodeParameter( + 'createData', + item, + ) as IDataObject; + const createFilters = this.getNodeParameter( + 'createQuery', + item, + ) as IDataObject; + const projection = this.getNodeParameter( + 'updateProjection', + item, + ) as string; + const encryptionHeaders = this.getNodeParameter( + 'encryptionHeaders', + item, + ) as IDataObject; + + // Determine content, content type and known length + let content: string | Buffer | Readable; + let contentType: string; + let knownLength: number; + + const useBinary = this.getNodeParameter( + 'createFromBinary', + item, + ) as boolean; + + if (useBinary) { + const binaryPropertyName = this.getNodeParameter( + 'createBinaryPropertyName', + item, + ) as string; + + const binaryData = this.helpers.assertBinaryData( + item, + binaryPropertyName, + ); + + if (binaryData.id) { + content = await this.helpers.getBinaryStream(binaryData.id); + const binaryMetadata = await this.helpers.getBinaryMetadata( + binaryData.id, + ); + contentType = + binaryMetadata.mimeType ?? 'application/octet-stream'; + knownLength = binaryMetadata.fileSize; + } else { + content = Buffer.from(binaryData.data, BINARY_ENCODING); + contentType = binaryData.mimeType; + knownLength = content.length; + } + } else { + content = this.getNodeParameter('createContent', item) as string; + contentType = + (createData['contentType'] as string) || 'text/plain'; + knownLength = content.length; + } + + const body = createMultipartForm( + parseBodyData(createData, bodyDataFields), + content, + contentType, + knownLength, + ); + + responseData = await googleApiRequest.call( + this, + 'POST', + new URL( + `https://storage.googleapis.com/upload/storage/v1/b/${bucketName}/o`, + ), + body, + { + name: objectName, + uploadType: 'multipart', + ...createFilters, + projection, + }, + { + 'Content-Length': body.getLengthSync(), + 'Content-Type': `multipart/related; boundary=${body.getBoundary()}`, + ...encryptionHeaders, + }, + ); + } + + if (operation === 'delete') { + const bucketName = this.getNodeParameter( + 'bucketName', + item, + ) as string; + const objectName = this.getNodeParameter( + 'objectName', + item, + ) as string; + const deleteParameters = this.getNodeParameter( + 'getParameters', + item, + ) as IDataObject; + + responseData = await googleApiRequest.call( + this, + 'DELETE', + `/b/${bucketName}/o/${objectName}`, + undefined, + deleteParameters, + ); + } + + if (operation === 'get') { + const bucketName = this.getNodeParameter( + 'bucketName', + item, + ) as string; + const objectName = this.getNodeParameter( + 'objectName', + item, + ) as string; + const alt = this.getNodeParameter('alt', item) as string; + const getParameters = this.getNodeParameter( + 'getParameters', + item, + ) as IDataObject; + const projection = this.getNodeParameter( + 'projection', + item, + ) as string; + const encryptionHeaders = this.getNodeParameter( + 'encryptionHeaders', + item, + ) as IDataObject; + + responseData = await googleApiRequest.call( + this, + 'GET', + `/b/${bucketName}/o/${objectName}`, + undefined, + { + alt, + ...getParameters, + projection, + }, + encryptionHeaders, + ); + } + + if (operation === 'getAll') { + const bucketName = this.getNodeParameter( + 'bucketName', + item, + ) as string; + const listFilters = this.getNodeParameter( + 'listFilters', + item, + ) as IDataObject; + const projection = this.getNodeParameter( + 'projection', + item, + ) as string; + const returnAll = this.getNodeParameter( + 'returnAll', + item, + ) as boolean; + const maxResults = this.getNodeParameter( + 'maxResults', + item, + ) as number; + + responseData = await googleApiRequestAllItems.call( + this, + 'GET', + `/b/${bucketName}/o`, + undefined, + { + ...listFilters, + projection, + ...(!returnAll && { + maxResults, + }), + }, + ); + } + + if (operation === 'update') { + const bucketName = this.getNodeParameter( + 'bucketName', + item, + ) as string; + const objectName = this.getNodeParameter( + 'objectName', + item, + ) as string; + const updateData = this.getNodeParameter( + 'updateData', + item, + ) as IDataObject; + const updateFilters = this.getNodeParameter( + 'metagenAndAclQuery', + item, + ) as IDataObject; + const projection = this.getNodeParameter( + 'updateProjection', + item, + ) as string; + const encryptionHeaders = this.getNodeParameter( + 'encryptionHeaders', + item, + ) as IDataObject; + + responseData = await googleApiRequest.call( + this, + 'PATCH', + `/b/${bucketName}/o/${objectName}`, + parseBodyData(updateData, bodyDataFields), + { + ...updateFilters, + projection, + }, + encryptionHeaders, + ); + } + } + + const executionData = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray(responseData), + { itemData: { item } }, + ); + + returnData.push(...executionData); + } catch (error: any) { + if (this.continueOnFail()) { + returnData.push({ error: error.message, json: {} }); + continue; + } + + throw error; + } + } + + return [returnData]; + } +} diff --git a/nodes/google-enhanced/src/nodes/GoogleCloudStorageEnhanced/googleCloudStorageEnhanced.json b/nodes/google-enhanced/src/nodes/GoogleCloudStorageEnhanced/googleCloudStorageEnhanced.json new file mode 100644 index 00000000..b9bbb4d7 --- /dev/null +++ b/nodes/google-enhanced/src/nodes/GoogleCloudStorageEnhanced/googleCloudStorageEnhanced.json @@ -0,0 +1,28 @@ +{ + "node": "n8n-nodes-base.googleCloudStorageEnhanced", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": ["Development", "Data & Storage"], + "resources": { + "credentialDocumentation": [ + { + "url": "https://docs.n8n.io/integrations/builtin/credentials/google/oauth-single-service/" + }, + { + "url": "https://docs.n8n.io/integrations/builtin/credentials/google/service-account/" + } + ], + "primaryDocumentation": [ + { + "url": "https://docs.n8n.io/integrations/builtin/app-nodes/n8n-nodes-base.googlecloudstorage/" + } + ], + "generic": [ + { + "label": "15 Google apps you can combine and automate to increase productivity", + "icon": "💡", + "url": "https://n8n.io/blog/automate-google-apps-for-productivity/" + } + ] + } +} diff --git a/nodes/google-enhanced/src/nodes/GoogleCloudStorageEnhanced/googleCloudStorageEnhanced.svg b/nodes/google-enhanced/src/nodes/GoogleCloudStorageEnhanced/googleCloudStorageEnhanced.svg new file mode 100644 index 00000000..72753232 --- /dev/null +++ b/nodes/google-enhanced/src/nodes/GoogleCloudStorageEnhanced/googleCloudStorageEnhanced.svg @@ -0,0 +1 @@ +Icon_24px_CloudStorage_Color diff --git a/nodes/google-enhanced/tsconfig.json b/nodes/google-enhanced/tsconfig.json new file mode 100644 index 00000000..18713437 --- /dev/null +++ b/nodes/google-enhanced/tsconfig.json @@ -0,0 +1,29 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "forceConsistentCasingInFileNames": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "strictBindCallApply": false, + "strictFunctionTypes": true, + "strictNullChecks": true, + "strictPropertyInitialization": true, + "noImplicitAny": true, + "noImplicitThis": true, + "useUnknownInCatchVariables": true, + "esModuleInterop": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/nodes/google-enhanced/tsconfig.lib.json b/nodes/google-enhanced/tsconfig.lib.json new file mode 100644 index 00000000..ef03d40e --- /dev/null +++ b/nodes/google-enhanced/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"], + "include": ["src/**/*.ts"] +} diff --git a/nodes/google-enhanced/tsconfig.spec.json b/nodes/google-enhanced/tsconfig.spec.json new file mode 100644 index 00000000..d41aea47 --- /dev/null +++ b/nodes/google-enhanced/tsconfig.spec.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/package-lock.json b/package-lock.json index 156bed3b..21d9a0e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,9 +28,12 @@ "eslint": "8.56.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-n8n-nodes-base": "^1.16.1", + "form-data": "^4.0.0", "jest": "^29.7.0", "jest-environment-node": "^29.7.0", "jest-mock-extended": "^3.0.5", + "jsonwebtoken": "^9.0.0", + "moment-timezone": "^0.5.28", "n8n": "1.29.1", "n8n-nodes-base": "^1.29.1", "n8n-workflow": "^1.29.1", diff --git a/package.json b/package.json index 69f1a669..5ae0c733 100644 --- a/package.json +++ b/package.json @@ -42,9 +42,12 @@ "eslint": "8.56.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-n8n-nodes-base": "^1.16.1", + "form-data": "^4.0.0", "jest": "^29.7.0", "jest-environment-node": "^29.7.0", "jest-mock-extended": "^3.0.5", + "jsonwebtoken": "^9.0.0", + "moment-timezone": "^0.5.28", "n8n": "1.29.1", "n8n-nodes-base": "^1.29.1", "n8n-workflow": "^1.29.1", diff --git a/tsconfig.base.json b/tsconfig.base.json index 74e0b5e4..498f64ef 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -17,6 +17,9 @@ "paths": { "@skriptfabrik/n8n-nodes-clockify-enhanced": [ "nodes/clockify-enhanced/src/index.ts" + ], + "@skriptfabrik/n8n-nodes-google-enhanced": [ + "nodes/google-enhanced/src/index.ts" ] } },