diff --git a/.gitignore b/.gitignore index e920c16..9d0fac8 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,9 @@ lib-cov # Coverage directory used by tools like istanbul coverage +# TypeScript build output +dist + # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) .grunt diff --git a/index.js b/index.js deleted file mode 100644 index 88a0cce..0000000 --- a/index.js +++ /dev/null @@ -1,136 +0,0 @@ -'use strict'; -// GCSAdapter -// Store Parse Files in Google Cloud Storage: https://cloud.google.com/storage -const { Storage } = require('@google-cloud/storage'); - -function requiredOrFromEnvironment(options, key, env) { - options[key] = options[key] || process.env[env]; - if (!options[key]) { - throw `GCSAdapter requires an ${key}`; - } - return options; -} - -function fromEnvironmentOrDefault(options, key, env, defaultValue) { - options[key] = options[key] || process.env[env] || defaultValue; - return options; -} - -function optionsFromArguments(args) { - let options = {}; - let projectIdOrOptions = args[0]; - if (typeof projectIdOrOptions == 'string') { - options.projectId = projectIdOrOptions; - options.keyFilename = args[1]; - options.bucket = args[2]; - let otherOptions = args[3]; - if (otherOptions) { - options.bucketPrefix = otherOptions.bucketPrefix; - options.directAccess = otherOptions.directAccess; - } - } else { - options = Object.assign({}, projectIdOrOptions); - } - options = fromEnvironmentOrDefault(options, 'projectId', 'GCP_PROJECT_ID', undefined); - options = fromEnvironmentOrDefault(options, 'keyFilename', 'GCP_KEYFILE_PATH', undefined); - options = requiredOrFromEnvironment(options, 'bucket', 'GCS_BUCKET'); - options = fromEnvironmentOrDefault(options, 'bucketPrefix', 'GCS_BUCKET_PREFIX', ''); - options = fromEnvironmentOrDefault(options, 'directAccess', 'GCS_DIRECT_ACCESS', false); - return options; -} - -/* -supported options - -*projectId / 'GCP_PROJECT_ID' -*keyFilename / 'GCP_KEYFILE_PATH' -*bucket / 'GCS_BUCKET' -{ bucketPrefix / 'GCS_BUCKET_PREFIX' defaults to '' -directAccess / 'GCS_DIRECT_ACCESS' defaults to false -*/ -function GCSAdapter() { - let options = optionsFromArguments(arguments); - - this._bucket = options.bucket; - this._bucketPrefix = options.bucketPrefix; - this._directAccess = options.directAccess; - - this._gcsClient = new Storage(options); -} - -GCSAdapter.prototype.createFile = function (filename, data, contentType) { - let params = { - metadata: { - contentType: contentType || 'application/octet-stream' - } - }; - - return new Promise((resolve, reject) => { - let file = this._gcsClient.bucket(this._bucket).file(this._bucketPrefix + filename); - // gcloud supports upload(file) not upload(bytes), so we need to stream. - var uploadStream = file.createWriteStream(params); - uploadStream.on('error', (err) => { - return reject(err); - }).on('finish', () => { - // Second call to set public read ACL after object is uploaded. - if (this._directAccess) { - file.makePublic((err, res) => { - if (err !== null) { - return reject(err); - } - resolve(); - }); - } else { - resolve(); - } - }); - uploadStream.write(data); - uploadStream.end(); - }); -} - -GCSAdapter.prototype.deleteFile = function (filename) { - return new Promise((resolve, reject) => { - let file = this._gcsClient.bucket(this._bucket).file(this._bucketPrefix + filename); - file.delete((err, res) => { - if (err !== null) { - return reject(err); - } - resolve(res); - }); - }); -} - -// Search for and return a file if found by filename. -// Returns a promise that succeeds with the buffer result from GCS, or fails with an error. -GCSAdapter.prototype.getFileData = function (filename) { - return new Promise((resolve, reject) => { - let file = this._gcsClient.bucket(this._bucket).file(this._bucketPrefix + filename); - // Check for existence, since gcloud-node seemed to be caching the result - file.exists((err, exists) => { - if (exists) { - file.download((err, data) => { - if (err !== null) { - return reject(err); - } - return resolve(data); - }); - } else { - reject(err); - } - }); - }); -} - -// Generates and returns the location of a file stored in GCS for the given request and filename. -// The location is the direct GCS link if the option is set, -// otherwise we serve the file through parse-server. -GCSAdapter.prototype.getFileLocation = function (config, filename) { - if (this._directAccess) { - return `https://storage.googleapis.com/${this._bucket}/${this._bucketPrefix + filename}`; - } - return (config.mount + '/files/' + config.applicationId + '/' + encodeURIComponent(filename)); -} - -module.exports = GCSAdapter; -module.exports.default = GCSAdapter; diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..de0cb89 --- /dev/null +++ b/index.ts @@ -0,0 +1,205 @@ +'use strict'; + +import { Storage, StorageOptions } from '@google-cloud/storage'; + +interface GCSAdapterOptions extends StorageOptions { + bucket?: string; + bucketPrefix?: string; + directAccess?: boolean | string; +} + +interface ResolvedGCSAdapterOptions extends StorageOptions { + bucket: string; + bucketPrefix: string; + directAccess: boolean; +} + +interface LegacyOptions { + bucketPrefix?: string; + directAccess?: boolean | string; +} + +type GCSClient = { + bucket(name: string): { + file(name: string): { + createWriteStream(params: { metadata: { contentType: string } }): NodeJS.WritableStream; + delete(callback: (err: Error | null, response?: unknown) => void): void; + download(callback: (err: Error | null, data?: Buffer) => void): void; + exists(callback: (err: Error | null, exists?: boolean) => void): void; + makePublic(callback: (err: Error | null, response?: unknown) => void): void; + }; + }; +}; + +function requiredOrFromEnvironment( + options: GCSAdapterOptions, + key: 'bucket', + env: string +): GCSAdapterOptions { + options[key] = options[key] || process.env[env]; + if (!options[key]) { + throw `GCSAdapter requires an ${key}`; + } + return options; +} + +function fromEnvironmentOrDefault( + options: GCSAdapterOptions, + key: K, + env: string, + defaultValue: GCSAdapterOptions[K] +): GCSAdapterOptions { + options[key] = options[key] || (process.env[env] as GCSAdapterOptions[K]) || defaultValue; + return options; +} + +function normalizeDirectAccess(directAccess: boolean | string | undefined): boolean { + if (typeof directAccess === 'boolean') { + return directAccess; + } + if (typeof directAccess === 'string') { + return ['true', '1', 'yes'].includes(directAccess.trim().toLowerCase()); + } + return false; +} + +function optionsFromArguments( + projectIdOrOptions?: string | GCSAdapterOptions, + keyFilename?: string, + bucket?: string, + otherOptions?: LegacyOptions +): ResolvedGCSAdapterOptions { + let options: GCSAdapterOptions = {}; + if (typeof projectIdOrOptions === 'string') { + options.projectId = projectIdOrOptions; + options.keyFilename = keyFilename; + options.bucket = bucket; + if (otherOptions) { + options.bucketPrefix = otherOptions.bucketPrefix; + options.directAccess = otherOptions.directAccess; + } + } else { + options = Object.assign({}, projectIdOrOptions); + } + options = fromEnvironmentOrDefault(options, 'projectId', 'GCP_PROJECT_ID', undefined); + options = fromEnvironmentOrDefault(options, 'keyFilename', 'GCP_KEYFILE_PATH', undefined); + options = requiredOrFromEnvironment(options, 'bucket', 'GCS_BUCKET'); + options = fromEnvironmentOrDefault(options, 'bucketPrefix', 'GCS_BUCKET_PREFIX', ''); + if (options.directAccess == null) { + options.directAccess = process.env.GCS_DIRECT_ACCESS || false; + } + options.directAccess = normalizeDirectAccess(options.directAccess); + return options as ResolvedGCSAdapterOptions; +} + +/* +supported options + +*projectId / 'GCP_PROJECT_ID' +*keyFilename / 'GCP_KEYFILE_PATH' +*bucket / 'GCS_BUCKET' +{ bucketPrefix / 'GCS_BUCKET_PREFIX' defaults to '' +directAccess / 'GCS_DIRECT_ACCESS' defaults to false +*/ +class GCSAdapter { + static default: typeof GCSAdapter; + + _bucket: string; + _bucketPrefix: string; + _directAccess: boolean; + _gcsClient: GCSClient; + + constructor( + projectIdOrOptions?: string | GCSAdapterOptions, + keyFilename?: string, + bucket?: string, + otherOptions?: LegacyOptions + ) { + const options = optionsFromArguments(projectIdOrOptions, keyFilename, bucket, otherOptions); + + this._bucket = options.bucket; + this._bucketPrefix = options.bucketPrefix; + this._directAccess = options.directAccess; + + this._gcsClient = new Storage(options); + } + + createFile(filename: string, data: Buffer | string, contentType?: string): Promise { + const params = { + metadata: { + contentType: contentType || 'application/octet-stream' + } + }; + + return new Promise((resolve, reject) => { + const file = this._gcsClient.bucket(this._bucket).file(this._bucketPrefix + filename); + // gcloud supports upload(file) not upload(bytes), so we need to stream. + const uploadStream = file.createWriteStream(params); + uploadStream.on('error', err => { + return reject(err); + }).on('finish', () => { + // Second call to set public read ACL after object is uploaded. + if (this._directAccess) { + file.makePublic(err => { + if (err !== null) { + return reject(err); + } + resolve(); + }); + } else { + resolve(); + } + }); + uploadStream.write(data); + uploadStream.end(); + }); + } + + deleteFile(filename: string): Promise { + return new Promise((resolve, reject) => { + const file = this._gcsClient.bucket(this._bucket).file(this._bucketPrefix + filename); + file.delete((err, res) => { + if (err !== null) { + return reject(err); + } + resolve(res); + }); + }); + } + + // Search for and return a file if found by filename. + // Returns a promise that succeeds with the buffer result from GCS, or fails with an error. + getFileData(filename: string): Promise { + return new Promise((resolve, reject) => { + const file = this._gcsClient.bucket(this._bucket).file(this._bucketPrefix + filename); + // Check for existence, since gcloud-node seemed to be caching the result + file.exists((err, exists) => { + if (err !== null) { + return reject(err); + } + if (!exists) { + return resolve(undefined); + } + file.download((downloadErr, data) => { + if (downloadErr !== null) { + return reject(downloadErr); + } + return resolve(data); + }); + }); + }); + } + + // Generates and returns the location of a file stored in GCS for the given request and filename. + // The location is the direct GCS link if the option is set, + // otherwise we serve the file through parse-server. + getFileLocation(config: { mount: string; applicationId: string }, filename: string): string { + if (this._directAccess) { + return `https://storage.googleapis.com/${this._bucket}/${this._bucketPrefix + filename}`; + } + return config.mount + '/files/' + config.applicationId + '/' + encodeURIComponent(filename); + } +} + +GCSAdapter.default = GCSAdapter; +export = GCSAdapter; diff --git a/package-lock.json b/package-lock.json index 3651800..7c3a655 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,12 +18,14 @@ "@semantic-release/github": "11.0.1", "@semantic-release/npm": "12.0.1", "@semantic-release/release-notes-generator": "14.0.1", + "@types/node": "^22.10.2", "codecov": "^3.8.3", "istanbul": "^0.4.2", "jasmine": "^2.4.1", "nyc": "15.1.0", "parse-server-conformance-tests": "^1.0.0", - "semantic-release": "24.2.0" + "semantic-release": "24.2.0", + "typescript": "^5.9.3" } }, "node_modules/@ampproject/remapping": { @@ -67,7 +69,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.18.2.tgz", "integrity": "sha512-A8pri1YJiC5UnkdrWcmfZTJTV85b4UXTAfImGmCfYmax4TR9Cw8sDS0MOk++Gp2mE/BefVJ5nwy5yzqNJbP/DQ==", "dev": true, - "peer": true, "dependencies": { "@ampproject/remapping": "^2.1.0", "@babel/code-frame": "^7.16.7", @@ -682,7 +683,6 @@ "resolved": "https://registry.npmjs.org/@octokit/core/-/core-6.1.2.tgz", "integrity": "sha512-hEb7Ma4cGJGEUNOAVmyfdB/3WirWMg5hDuNFVejGEDFqupeOysLc2sG6HJxY2etBp5YQu5Wtxwi020jS9xlUwg==", "dev": true, - "peer": true, "dependencies": { "@octokit/auth-token": "^5.0.0", "@octokit/graphql": "^8.0.0", @@ -1302,12 +1302,12 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.3.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.1.tgz", - "integrity": "sha512-hj9YIJimBCipHVfHKRMnvmHg+wfhKc0o4mTtXh9pKBjC8TLJzz0nzGmLi5UJsYAUgSvXFHgb0V2oY10DUFtImw==", + "version": "22.10.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.2.tgz", + "integrity": "sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ==", "license": "MIT", "dependencies": { - "undici-types": "~7.18.0" + "undici-types": "~6.20.0" } }, "node_modules/@types/normalize-package-data": { @@ -4234,7 +4234,6 @@ "resolved": "https://registry.npmjs.org/marked/-/marked-12.0.2.tgz", "integrity": "sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==", "dev": true, - "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -8577,7 +8576,6 @@ "resolved": "https://registry.npmjs.org/semantic-release/-/semantic-release-24.2.0.tgz", "integrity": "sha512-fQfn6e/aYToRtVJYKqneFM1Rg3KP2gh3wSWtpYsLlz6uaPKlISrTzvYAFn+mYWo07F0X1Cz5ucU89AVE8X1mbg==", "dev": true, - "peer": true, "dependencies": { "@semantic-release/commit-analyzer": "^13.0.0-beta.1", "@semantic-release/error": "^4.0.0", @@ -9618,6 +9616,20 @@ "is-typedarray": "^1.0.0" } }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/uglify-js": { "version": "3.15.5", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.15.5.tgz", @@ -9632,9 +9644,9 @@ } }, "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", "license": "MIT" }, "node_modules/unicode-emoji-modifier-base": { diff --git a/package.json b/package.json index 412e65b..60fc0ef 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,8 @@ "name": "@parse/gcs-files-adapter", "version": "2.1.0", "description": "Google Cloud Storage adapter for parse-server", - "main": "index.js", + "main": "dist/index.js", + "types": "dist/index.d.ts", "keywords": [ "parse-server", "google", @@ -17,6 +18,9 @@ "homepage": "https://github.com/parse-community/parse-server-gcs-adapter#readme", "author": "Parse", "license": "MIT", + "files": [ + "dist" + ], "dependencies": { "@google-cloud/storage": "7.19.0" }, @@ -27,15 +31,19 @@ "@semantic-release/github": "11.0.1", "@semantic-release/npm": "12.0.1", "@semantic-release/release-notes-generator": "14.0.1", + "@types/node": "^22.10.2", "codecov": "^3.8.3", "istanbul": "^0.4.2", "jasmine": "^2.4.1", "nyc": "15.1.0", "parse-server-conformance-tests": "^1.0.0", - "semantic-release": "24.2.0" + "semantic-release": "24.2.0", + "typescript": "^5.9.3" }, "scripts": { - "test": "jasmine", - "coverage": "nyc jasmine" + "build": "node ./node_modules/typescript/bin/tsc -p tsconfig.json", + "prepack": "node ./node_modules/typescript/bin/tsc -p tsconfig.json", + "test": "node ./node_modules/typescript/bin/tsc -p tsconfig.json && node ./node_modules/jasmine/bin/jasmine.js", + "coverage": "node ./node_modules/typescript/bin/tsc -p tsconfig.json && node ./node_modules/nyc/bin/nyc.js node ./node_modules/jasmine/bin/jasmine.js" } } diff --git a/spec/test.spec.js b/spec/test.spec.js index 20c9fe3..c7f2acb 100644 --- a/spec/test.spec.js +++ b/spec/test.spec.js @@ -1,7 +1,9 @@ 'use strict'; let filesAdapterTests = require('parse-server-conformance-tests').files; +let fs = require('fs'); +let path = require('path'); -let GCSAdapter = require('../index.js'); +let GCSAdapter = require('../dist/index.js'); describe('GCSAdapter tests', () => { @@ -37,6 +39,21 @@ describe('GCSAdapter tests', () => { }).not.toThrow(); }); + describe('package entrypoints', () => { + it('publishes compiled JavaScript and TypeScript declarations', () => { + let packageJson = require('../package.json'); + + expect(packageJson.main).toBe('dist/index.js'); + expect(packageJson.types).toBe('dist/index.d.ts'); + if (packageJson.main) { + expect(fs.existsSync(path.join(__dirname, '..', packageJson.main))).toBe(true); + } + if (packageJson.types) { + expect(fs.existsSync(path.join(__dirname, '..', packageJson.types))).toBe(true); + } + }); + }); + describe('deleteFile', () => { let gcsAdapter; let mockStorage; @@ -128,6 +145,104 @@ describe('GCSAdapter tests', () => { }); }); + describe('directAccess', () => { + let previousDirectAccess; + + beforeEach(() => { + previousDirectAccess = process.env.GCS_DIRECT_ACCESS; + }); + + afterEach(() => { + if (previousDirectAccess === undefined) { + delete process.env.GCS_DIRECT_ACCESS; + } else { + process.env.GCS_DIRECT_ACCESS = previousDirectAccess; + } + }); + + it('should treat GCS_DIRECT_ACCESS=false as disabled', () => { + process.env.GCS_DIRECT_ACCESS = 'false'; + + let gcsAdapter = new GCSAdapter({ + projectId: 'projectId', + keyFilename: 'keyFilename', + bucket: 'bucket', + bucketPrefix: 'prefix/' + }); + + expect(gcsAdapter.getFileLocation({ + mount: '/parse', + applicationId: 'app' + }, 'my file.txt')).toBe('/parse/files/app/my%20file.txt'); + }); + + it('should prefer an explicit directAccess option over GCS_DIRECT_ACCESS', () => { + process.env.GCS_DIRECT_ACCESS = 'true'; + + let gcsAdapter = new GCSAdapter({ + projectId: 'projectId', + keyFilename: 'keyFilename', + bucket: 'bucket', + bucketPrefix: 'prefix/', + directAccess: false + }); + + expect(gcsAdapter.getFileLocation({ + mount: '/parse', + applicationId: 'app' + }, 'my file.txt')).toBe('/parse/files/app/my%20file.txt'); + }); + }); + + describe('getFileData', () => { + let gcsAdapter; + let mockStorage; + let mockBucket; + let mockFile; + let mockExists; + let mockDownload; + + beforeEach(() => { + mockExists = jasmine.createSpy('exists'); + mockDownload = jasmine.createSpy('download'); + mockFile = { + exists: mockExists, + download: mockDownload + }; + mockBucket = jasmine.createSpyObj('bucket', ['file']); + mockBucket.file.and.returnValue(mockFile); + mockStorage = jasmine.createSpyObj('storage', ['bucket']); + mockStorage.bucket.and.returnValue(mockBucket); + + gcsAdapter = new GCSAdapter({ + projectId: 'projectId', + keyFilename: 'keyFilename', + bucket: 'bucket', + bucketPrefix: 'prefix/' + }); + gcsAdapter._gcsClient = mockStorage; + }); + + it('should resolve undefined when the file does not exist', (done) => { + mockExists.and.callFake((callback) => { + callback(null, false); + }); + + gcsAdapter.getFileData('missing.txt') + .then((data) => { + expect(data).toBeUndefined(); + expect(mockStorage.bucket).toHaveBeenCalledWith('bucket'); + expect(mockBucket.file).toHaveBeenCalledWith('prefix/missing.txt'); + expect(mockDownload).not.toHaveBeenCalled(); + done(); + }) + .catch((err) => { + fail('Promise should not reject: ' + err); + done(); + }); + }); + }); + if (process.env.GCP_PROJECT_ID && process.env.GCP_KEYFILE_PATH && process.env.GCS_BUCKET) { // Should be initialized from the env let gcsAdapter = new GCSAdapter(); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..dc02164 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "declaration": true, + "esModuleInterop": true, + "lib": ["ES2020"], + "module": "commonjs", + "moduleResolution": "node", + "outDir": "dist", + "rootDir": ".", + "skipLibCheck": true, + "strict": true, + "target": "ES2020" + }, + "include": ["index.ts"] +}