From 479f10899508792e6f56935e44534cd81a8dcdec Mon Sep 17 00:00:00 2001 From: Thiyagu K Date: Thu, 19 Feb 2026 05:44:55 +0000 Subject: [PATCH 1/6] WIP --- samples/uploadFileWithContexts.js | 44 +++++++++++++++++++++++++++++++ src/file.ts | 11 ++++++++ 2 files changed, 55 insertions(+) create mode 100644 samples/uploadFileWithContexts.js diff --git a/samples/uploadFileWithContexts.js b/samples/uploadFileWithContexts.js new file mode 100644 index 000000000..b6c646755 --- /dev/null +++ b/samples/uploadFileWithContexts.js @@ -0,0 +1,44 @@ +const path = require('path'); + +function main( + bucketName = 'my-bucket', + filePath = path.join(__dirname, './resources', 'test.txt'), + destFileName = 'file.txt', + generationMatchPrecondition = 0 +) { + // [START storage_upload_file_with_contexts] + const {Storage} = require('@google-cloud/storage'); + const storage = new Storage(); + + async function uploadFileWithContexts() { + const options = { + destination: destFileName, + preconditionOpts: {ifGenerationMatch: generationMatchPrecondition}, + // New Object Contexts Feature Logic + metadata: { + contexts: { + custom: { + department: { + value: 'engineering', + }, + environment: { + value: 'production', + }, + }, + }, + }, + }; + + await storage.bucket(bucketName).upload(filePath, options); + + console.log(`${filePath} uploaded to ${bucketName} with custom contexts.`); + console.log( + 'Note: createTime and updateTime will be generated by the server.' + ); + } + + uploadFileWithContexts().catch(console.error); + // [END storage_upload_file_with_contexts] +} + +main(...process.argv.slice(2)); diff --git a/src/file.ts b/src/file.ts index 5c6f4e681..86a95b373 100644 --- a/src/file.ts +++ b/src/file.ts @@ -469,6 +469,12 @@ export interface RestoreOptions extends PreconditionOptions { projection?: 'full' | 'noAcl'; } +export interface ContextValue { + value: string | null; + readonly createTime?: string; + readonly updateTime?: string; +} + export interface FileMetadata extends BaseMetadata { acl?: AclMetadata[] | null; bucket?: string; @@ -483,6 +489,11 @@ export interface FileMetadata extends BaseMetadata { encryptionAlgorithm?: string; keySha256?: string; }; + contexts?: { + custom: { + [key: string]: ContextValue; + } | null; + }; customTime?: string; eventBasedHold?: boolean | null; readonly eventBasedHoldReleaseTime?: string; From 8fbb7580293a0d0be02d48b20f4ee49a08b48e75 Mon Sep 17 00:00:00 2001 From: Thiyagu K Date: Fri, 6 Mar 2026 10:18:38 +0000 Subject: [PATCH 2/6] feat(storage): add Object Contexts support to GCS metadata and listing Updated internal request mapping in `file.ts` and `bucket.ts` to include `contexts` in JSON payloads and `filter` in query strings. Fixed baseline unit tests to accommodate the updated destination metadata structure. --- src/bucket.ts | 21 +++- src/file.ts | 30 +++++ src/util.ts | 27 +++++ system-test/storage.ts | 255 +++++++++++++++++++++++++++++++++++++++++ test/bucket.ts | 67 +++++++++++ test/file.ts | 204 +++++++++++++++++++++++++++++++++ 6 files changed, 603 insertions(+), 1 deletion(-) diff --git a/src/bucket.ts b/src/bucket.ts index d35420905..3bb7085ff 100644 --- a/src/bucket.ts +++ b/src/bucket.ts @@ -34,7 +34,7 @@ import * as path from 'path'; import pLimit from 'p-limit'; import {promisify} from 'util'; import AsyncRetry from 'async-retry'; -import {convertObjKeysToSnakeCase} from './util.js'; +import {convertObjKeysToSnakeCase, validateContexts} from './util.js'; import {Acl, AclMetadata} from './acl.js'; import {Channel} from './channel.js'; @@ -44,6 +44,7 @@ import { CreateResumableUploadOptions, CreateWriteStreamOptions, FileMetadata, + ContextValue, } from './file.js'; import {Iam} from './iam.js'; import {Notification, NotificationMetadata} from './notification.js'; @@ -178,11 +179,17 @@ export interface GetFilesOptions { userProject?: string; versions?: boolean; fields?: string; + filter?: string; } export interface CombineOptions extends PreconditionOptions { kmsKeyName?: string; userProject?: string; + contexts?: { + custom: { + [key: string]: ContextValue; + } | null; + }; } export interface CombineCallback { @@ -1628,6 +1635,17 @@ class Bucket extends ServiceObject { options = optionsOrCallback; } + if (options.contexts) { + try { + validateContexts({contexts: options.contexts}); + } catch (err) { + if (callback) { + return (callback as CombineCallback)(err as Error, null, null); + } + return Promise.reject(err); + } + } + this.disableAutoRetryConditionallyIdempotent_( this.methods.setMetadata, // Not relevant but param is required AvailableServiceObjectMethods.setMetadata, // Same as above @@ -1682,6 +1700,7 @@ class Bucket extends ServiceObject { destination: { contentType: destinationFile.metadata.contentType, contentEncoding: destinationFile.metadata.contentEncoding, + contexts: options.contexts || destinationFile.metadata.contexts, }, sourceObjects: (sources as File[]).map(source => { const sourceObject = { diff --git a/src/file.ts b/src/file.ts index 86a95b373..793580711 100644 --- a/src/file.ts +++ b/src/file.ts @@ -61,6 +61,7 @@ import { unicodeJSONStringify, formatAsUTCISO, PassThroughShim, + validateContexts, } from './util.js'; import {CRC32C, CRC32CValidatorGenerator} from './crc32c.js'; import {HashStreamValidator} from './hash-stream-validator.js'; @@ -382,6 +383,11 @@ export interface CopyOptions { metadata?: { [key: string]: string | boolean | number | null; }; + contexts?: { + custom: { + [key: string]: ContextValue; + } | null; + }; predefinedAcl?: string; token?: string; userProject?: string; @@ -1303,6 +1309,16 @@ class File extends ServiceObject { options = {...optionsOrCallback}; } + if (options.contexts) { + try { + validateContexts({contexts: options.contexts}); + } catch (err) { + if (callback) + return (callback as CopyCallback)(err as Error, null, null); + return Promise.reject(err); + } + } + callback = callback || util.noop; let destBucket: Bucket; @@ -4134,6 +4150,13 @@ class File extends ServiceObject { const options = typeof optionsOrCallback === 'object' ? optionsOrCallback : {}; + try { + validateContexts(options.metadata); + } catch (err) { + if (callback) return callback(err as Error); + return Promise.reject(err); + } + let maxRetries = this.storage.retryOptions.maxRetries; if ( !this.shouldRetryBasedOnPreconditionAndIdempotencyStrat( @@ -4237,6 +4260,13 @@ class File extends ServiceObject { ? (optionsOrCallback as MetadataCallback) : cb; + try { + validateContexts(metadata); + } catch (err) { + if (cb) return cb(err as Error); + return Promise.reject(err); + } + this.disableAutoRetryConditionallyIdempotent_( this.methods.setMetadata, AvailableServiceObjectMethods.setMetadata, diff --git a/src/util.ts b/src/util.ts index 259f7c0f3..a9b495491 100644 --- a/src/util.ts +++ b/src/util.ts @@ -19,6 +19,7 @@ import * as url from 'url'; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore import {getPackageJSON} from './package-json-helper.cjs'; +import {FileMetadata} from './file'; // Done to avoid a problem with mangling of identifiers when using esModuleInterop const fileURLToPath = url.fileURLToPath; @@ -272,3 +273,29 @@ export class PassThroughShim extends PassThrough { callback(null); } } + +/** + * Validates Object Contexts for forbidden characters. + * Double quotes (") are forbidden in context keys and values as they + * interfere with GCS filter string syntax. + * + * @param {FileMetadata} [metadata] The metadata object to validate. + * @returns {boolean} Returns `true` if valid, throws with error otherwise. + */ +export function validateContexts(metadata?: FileMetadata) { + const custom = metadata?.contexts?.custom; + if (!custom) return; + + for (const [key, context] of Object.entries(custom)) { + if (key.includes('"')) { + throw new Error( + `Invalid context key "${key}": Forbidden character (") detected.` + ); + } + if (context?.value && context.value.includes('"')) { + throw new Error( + `Invalid context value for key "${key}": Forbidden character (") detected.` + ); + } + } +} diff --git a/system-test/storage.ts b/system-test/storage.ts index 15257fb59..c285ba59b 100644 --- a/system-test/storage.ts +++ b/system-test/storage.ts @@ -3535,6 +3535,261 @@ describe('storage', function () { }); }); + describe('object contexts', () => { + after(async () => { + await bucket.deleteFiles(); + }); + + it('should create, retrieve, and update object contexts', async () => { + const file = bucket.file('test-context-obj.txt'); + const initialContexts = { + custom: { + 'team-owner': {value: 'storage-team'}, + priority: {value: 'high'}, + }, + }; + + await file.save('hello world', { + metadata: {contexts: initialContexts}, + }); + + const [metadata] = await file.getMetadata(); + assert.ok(metadata.contexts?.custom); + assert.strictEqual( + metadata.contexts.custom['team-owner'].value, + 'storage-team' + ); + assert.ok(metadata.contexts.custom['team-owner'].createTime); + + const patchMetadata = { + contexts: { + custom: { + priority: {value: 'critical'}, // Update existing + env: {value: 'prod'}, // Add new + 'team-owner': null, // Remove existing + }, + }, + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await file.setMetadata(patchMetadata as any); + + const [updatedMetadata] = await file.getMetadata(); + const finalCustom = updatedMetadata.contexts!.custom!; + assert.strictEqual(finalCustom['priority'].value, 'critical'); + assert.strictEqual(finalCustom['env'].value, 'prod'); + assert.strictEqual(finalCustom['team-owner'], undefined); + assert.ok(finalCustom['priority'].updateTime); + }); + + it('should get contexts and server-generated timestamps in response', async () => { + const file = bucket.file('test-context-obj.txt'); + await file.save('data', { + metadata: {contexts: {custom: {status: {value: 'active'}}}}, + }); + + const [metadata] = await file.getMetadata(); + + assert.ok(metadata.contexts?.custom?.status); + const context = metadata.contexts.custom.status; + assert.strictEqual(context.value, 'active'); + assert.ok(context.createTime); + assert.ok(context.updateTime); + }); + + it('should clear all contexts of an existing object', async () => { + const file = bucket.file('test-context-obj-clear-all.txt'); + await file.save('data', { + metadata: { + contexts: { + custom: { + 'temp-key': {value: 'temp'}, + status: {value: 'to-be-cleared'}, + }, + }, + }, + }); + + await file.setMetadata({ + contexts: { + custom: null, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + const [metadata] = await file.getMetadata(); + + assert.strictEqual(metadata.contexts?.custom, undefined); + }); + + describe('copy/rewrite object with contexts', () => { + it('should inherit contexts from the source by default', async () => { + const source = bucket.file('test-context-obj-src-copy.txt'); + const dest = bucket.file('test-context-obj-dest-copy.txt'); + + await source.save('content', { + metadata: {contexts: {custom: {tag: {value: 'original'}}}}, + }); + + await source.copy(dest); + + const [metadata] = await dest.getMetadata(); + assert.strictEqual(metadata.contexts?.custom?.tag?.value, 'original'); + }); + + it('should override contexts during copy', async () => { + const source = bucket.file('test-context-obj-src-ovr.txt'); + const dest = bucket.file('test-context-obj-dest-ovr.txt'); + + await source.save('content', { + metadata: {contexts: {custom: {tag: {value: 'original'}}}}, + }); + + await source.copy(dest, { + contexts: {custom: {tag: {value: 'overridden'}}}, + }); + + const [metadata] = await dest.getMetadata(); + assert.strictEqual(metadata.contexts?.custom?.tag?.value, 'overridden'); + }); + }); + + describe('combine object with contexts', () => { + it('should inherit contexts from the first source object', async () => { + const file1 = bucket.file('test-context-obj-c1.txt'); + const file2 = bucket.file('test-context-obj-c2.txt'); + const combined = bucket.file('test-context-obj-combined.txt'); + + await file1.save('a', { + metadata: {contexts: {custom: {source: {value: 'file1'}}}}, + }); + await file2.save('b'); + + await bucket.combine([file1, file2], combined); + + const [metadata] = await combined.getMetadata(); + assert.strictEqual(metadata.contexts?.custom?.source?.value, 'file1'); + }); + + it('should override contexts for the composed object', async () => { + const file1 = bucket.file('test-context-obj-o1.txt'); + const file2 = bucket.file('test-context-obj-o2.txt'); + const combined = bucket.file('test-context-obj-combined-ovr.txt'); + + await file1.save('a'); + await file2.save('b'); + + await bucket.combine([file1, file2], combined, { + contexts: {custom: {status: {value: 'composed'}}}, + }); + + const [metadata] = await combined.getMetadata(); + assert.strictEqual( + metadata.contexts?.custom?.status?.value, + 'composed' + ); + }); + }); + + describe('list objects with contexts filter', () => { + const FILE_ACTIVE = bucket.file('test-context-obj-filter-active.txt'); + const FILE_INACTIVE = bucket.file('test-context-obj-filter-inactive.txt'); + const FILE_NO_CONTEXT = bucket.file('test-context-obj-filter-none.txt'); + + before(async () => { + await bucket.deleteFiles(); + await Promise.all([ + FILE_ACTIVE.save('content', { + metadata: {contexts: {custom: {status: {value: 'active'}}}}, + }), + FILE_INACTIVE.save('content', { + metadata: {contexts: {custom: {status: {value: 'inactive'}}}}, + }), + FILE_NO_CONTEXT.save('content'), + ]); + }); + + it('should list all objects matching a prefix', async () => { + const [files] = await bucket.getFiles(); + assert.strictEqual(files.length, 3); + }); + + it('should filter by presence of key/value pair', async () => { + const query = { + filter: 'contexts."status"="active"', + }; + const [files] = await bucket.getFiles(query); + + assert.strictEqual(files.length, 1); + assert.strictEqual(files[0].name, FILE_ACTIVE.name); + }); + + it('should filter by absence of key/value pair (NOT)', async () => { + const query = { + filter: '-contexts."status"="active"', + }; + const [files] = await bucket.getFiles(query); + + assert.strictEqual(files.length, 2); + const names = files.map(f => f.name); + assert.ok(names.includes(FILE_INACTIVE.name)); + assert.ok(names.includes(FILE_NO_CONTEXT.name)); + }); + + it('should filter by presence of key regardless of value (Existence)', async () => { + const query = { + filter: 'contexts."status":*', + }; + const [files] = await bucket.getFiles(query); + + assert.strictEqual(files.length, 2); + const names = files.map(f => f.name); + assert.ok(names.includes(FILE_ACTIVE.name)); + assert.ok(names.includes(FILE_INACTIVE.name)); + }); + + it('should filter by absence of key regardless of value (Non-existence)', async () => { + const query = { + filter: '-contexts."status":*', + }; + const [files] = await bucket.getFiles(query); + + assert.strictEqual(files.length, 1); + assert.strictEqual(files[0].name, FILE_NO_CONTEXT.name); + }); + + it('should return empty list when no contexts match the filter', async () => { + const query = { + filter: 'contexts."status"="non-existent"', + }; + + const [files] = await bucket.getFiles(query); + + assert.strictEqual(files.length, 0); + }); + + it('should correctly handle double quotes in filter keys', async () => { + const file = bucket.file('test-context-quoted-test.txt'); + await file.save('data', { + metadata: { + contexts: { + custom: { + priority: {value: 'quoted-val'}, + }, + }, + }, + }); + const query = { + filter: 'contexts."priority"="quoted-val"', + }; + + const [files] = await bucket.getFiles(query); + + assert.strictEqual(files.length, 1); + assert.strictEqual(files[0].name, file.name); + await file.delete(); + }); + }); + }); + describe('offset', () => { const NEW_FILES = [ bucket.file('startOffset_file1'), diff --git a/test/bucket.ts b/test/bucket.ts index 5b49fa518..b284c5f3a 100644 --- a/test/bucket.ts +++ b/test/bucket.ts @@ -755,6 +755,7 @@ describe('Bucket', () => { destination: { contentType: mime.getType(destination.name) || undefined, contentEncoding: undefined, + contexts: undefined, }, sourceObjects: [{name: sources[0].name}, {name: sources[1].name}], }); @@ -2007,6 +2008,72 @@ describe('Bucket', () => { done(); }); }); + + it('should filter by presence of key/value pair', done => { + const filter = 'contexts."status"="active"'; + bucket.request = (reqOpts: DecorateRequestOptions) => { + assert.strictEqual(reqOpts.qs.filter, filter); + done(); + }; + + bucket.getFiles({filter}, util.noop); + }); + + it('should filter by absence of key/value pair (NOT)', done => { + const filter = 'NOT contexts."status"="active"'; + bucket.request = (reqOpts: DecorateRequestOptions) => { + assert.strictEqual(reqOpts.qs.filter, filter); + done(); + }; + + bucket.getFiles({filter}, util.noop); + }); + + it('should filter by presence of key regardless of value (Existence)', done => { + const filter = 'contexts."status":*'; + bucket.request = (reqOpts: DecorateRequestOptions) => { + assert.strictEqual(reqOpts.qs.filter, filter); + done(); + }; + + bucket.getFiles({filter}, util.noop); + }); + + it('should filter by absence of key regardless of value (Non-existence)', done => { + const filter = 'NOT contexts."status":*'; + bucket.request = (reqOpts: DecorateRequestOptions) => { + assert.strictEqual(reqOpts.qs.filter, filter); + done(); + }; + + bucket.getFiles({filter}, util.noop); + }); + + it('should include contexts in the returned File metadata', done => { + const fileMetadata = { + name: 'filename', + contexts: { + custom: { + dept: {value: 'eng', createTime: '...'}, + }, + }, + }; + bucket.request = ( + reqOpts: DecorateRequestOptions, + callback: Function + ) => { + callback(null, {items: [fileMetadata]}); + }; + + bucket.getFiles((err: Error, files: FakeFile[]) => { + assert.ifError(err); + assert.deepStrictEqual( + files[0].metadata.contexts, + fileMetadata.contexts + ); + done(); + }); + }); }); describe('getLabels', () => { diff --git a/test/file.ts b/test/file.ts index b9a96f8cb..3a1984287 100644 --- a/test/file.ts +++ b/test/file.ts @@ -5043,6 +5043,210 @@ describe('File', () => { }); }); + describe('Object Contexts', () => { + describe('Create a new object', () => { + it('should include valid contexts in the upload request', async () => { + const metadata = { + contexts: { + custom: { + dept: {value: 'eng'}, + env: {value: 'prod'}, + }, + }, + }; + + const stub = sinon.stub(file, 'save').resolves(); + await file.save('data', {metadata}); + + assert.strictEqual(stub.calledOnce, true); + + const callArgs = stub.getCall(0).args[1]; + assert.ok(callArgs); + + const sentMetadata = callArgs!.metadata; + assert.ok(sentMetadata); + assert.strictEqual(sentMetadata!.contexts!.custom!.dept.value, 'eng'); + }); + + it('should handle Unicode characters in keys and values', async () => { + const metadata = { + contexts: { + custom: { + '🚀-launcher': {value: '✨-sparkle'}, + }, + }, + }; + + const stub = sinon.stub(file, 'save').resolves(); + await file.save('data', {metadata}); + + const options = stub.getCall(0).args[1]; + const {contexts} = options!.metadata!; + + assert.strictEqual( + contexts!.custom!['🚀-launcher'].value, + '✨-sparkle' + ); + }); + + it('should throw an error for invalid characters (double quotes) in keys', async () => { + const metadata = { + contexts: { + custom: { + 'invalid"key': {value: 'some-value'}, + }, + }, + }; + + try { + await file.save('data', {metadata}); + assert.fail('Should have thrown validation error'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (err: any) { + assert.ok(err.message.includes('Forbidden character')); + } + }); + }); + + describe('Update/Patch an existing object', () => { + it('should replace all contexts (PUT semantics)', async () => { + const newMetadata = { + contexts: { + custom: {'only-key': {value: 'only-val'}}, + }, + }; + + const stub = sinon.stub(file, 'setMetadata').resolves(); + await file.setMetadata(newMetadata); + + const sentMetadata = stub.getCall(0).args[0]; + + assert.ok(sentMetadata.contexts); + assert.ok(sentMetadata.contexts!.custom); + assert.strictEqual( + sentMetadata.contexts!.custom!['only-key'].value, + 'only-val' + ); + assert.strictEqual( + sentMetadata.contexts!.custom!['new-key'], + undefined + ); + }); + + it('should add/modify individual contexts (PATCH semantics)', async () => { + const patchMetadata = { + contexts: { + custom: { + 'new-key': {value: 'added'}, + 'existing-key': {value: 'modified'}, + }, + }, + }; + + const stub = sinon.stub(file, 'setMetadata').resolves(); + await file.setMetadata(patchMetadata); + + const sentMetadata = stub.getCall(0).args[0]!; + + assert.ok(sentMetadata.contexts); + assert.ok(sentMetadata.contexts!.custom); + assert.strictEqual( + sentMetadata.contexts!.custom!['new-key'].value, + 'added' + ); + }); + + it('should remove an individual context by setting it to null', async () => { + const patchMetadata = { + contexts: { + custom: { + 'key-to-delete': null, + }, + }, + }; + + const stub = sinon.stub(file, 'setMetadata').resolves(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await file.setMetadata(patchMetadata as any); + + const sentMetadata = stub.getCall(0).args[0]; + const custom = sentMetadata.contexts!.custom!; + assert.strictEqual(custom['key-to-delete'], null); + }); + + it('should clear all contexts by setting custom to null', async () => { + const clearMetadata = { + contexts: { + custom: null, + }, + }; + + const stub = sinon.stub(file, 'setMetadata').resolves(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await file.setMetadata(clearMetadata as any); + const sentMetadata = stub.getCall(0).args[0]; + assert.strictEqual(sentMetadata.contexts!.custom, null); + }); + }); + + describe('Copying/Rewriting an object', () => { + it('should include contexts when copying an object with overrides', async () => { + const destFile = BUCKET.file('destination.txt'); + const metadata = { + contexts: { + custom: {tag: {value: 'overridden'}}, + }, + }; + + const stub = sinon.stub(file, 'copy').resolves(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await file.copy(destFile, {metadata} as any); + + assert.strictEqual(stub.calledOnce, true); + const options = stub.getCall(0).args[1]; + assert.deepStrictEqual(options.metadata.contexts, metadata.contexts); + }); + }); + + describe('Composing objects', () => { + it('should pass contexts to the destination object during combine', async () => { + const sources = [BUCKET.file('src1.txt'), BUCKET.file('src2.txt')]; + const combinedFile = BUCKET.file('combined.txt'); + const metadata = { + contexts: { + custom: {status: {value: 'composed'}}, + }, + }; + + const stub = sinon.stub(BUCKET, 'combine').resolves(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await BUCKET.combine(sources, combinedFile, {metadata} as any); + + const callOptions = stub.getCall(0).args[2]; + assert.deepStrictEqual( + callOptions.metadata.contexts, + metadata.contexts + ); + }); + }); + + it('should handle empty string values in contexts', async () => { + const metadata = { + contexts: { + custom: {'empty-key': {value: ''}}, + }, + }; + + const stub = sinon.stub(file, 'save').resolves(); + await file.save('data', {metadata}); + + const sentMetadata = stub.getCall(0).args[1].metadata; + assert.strictEqual(sentMetadata.contexts.custom['empty-key'].value, ''); + }); + }); + describe('setStorageClass', () => { const STORAGE_CLASS = 'new_storage_class'; From b91ae26219694e22433483be967b3b0ff5e6e857 Mon Sep 17 00:00:00 2001 From: Thiyagu K Date: Fri, 6 Mar 2026 10:23:53 +0000 Subject: [PATCH 3/6] sample removed --- samples/uploadFileWithContexts.js | 44 ------------------------------- 1 file changed, 44 deletions(-) delete mode 100644 samples/uploadFileWithContexts.js diff --git a/samples/uploadFileWithContexts.js b/samples/uploadFileWithContexts.js deleted file mode 100644 index b6c646755..000000000 --- a/samples/uploadFileWithContexts.js +++ /dev/null @@ -1,44 +0,0 @@ -const path = require('path'); - -function main( - bucketName = 'my-bucket', - filePath = path.join(__dirname, './resources', 'test.txt'), - destFileName = 'file.txt', - generationMatchPrecondition = 0 -) { - // [START storage_upload_file_with_contexts] - const {Storage} = require('@google-cloud/storage'); - const storage = new Storage(); - - async function uploadFileWithContexts() { - const options = { - destination: destFileName, - preconditionOpts: {ifGenerationMatch: generationMatchPrecondition}, - // New Object Contexts Feature Logic - metadata: { - contexts: { - custom: { - department: { - value: 'engineering', - }, - environment: { - value: 'production', - }, - }, - }, - }, - }; - - await storage.bucket(bucketName).upload(filePath, options); - - console.log(`${filePath} uploaded to ${bucketName} with custom contexts.`); - console.log( - 'Note: createTime and updateTime will be generated by the server.' - ); - } - - uploadFileWithContexts().catch(console.error); - // [END storage_upload_file_with_contexts] -} - -main(...process.argv.slice(2)); From fa9586ab15b6dc2c62bf04c17f5b69afa9798813 Mon Sep 17 00:00:00 2001 From: Thiyagu K Date: Fri, 6 Mar 2026 10:35:09 +0000 Subject: [PATCH 4/6] fix comments --- src/file.ts | 5 +++-- src/util.ts | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/file.ts b/src/file.ts index 793580711..c8b4ef375 100644 --- a/src/file.ts +++ b/src/file.ts @@ -497,7 +497,7 @@ export interface FileMetadata extends BaseMetadata { }; contexts?: { custom: { - [key: string]: ContextValue; + [key: string]: ContextValue | null; } | null; }; customTime?: string; @@ -1313,8 +1313,9 @@ class File extends ServiceObject { try { validateContexts({contexts: options.contexts}); } catch (err) { - if (callback) + if (callback) { return (callback as CopyCallback)(err as Error, null, null); + } return Promise.reject(err); } } diff --git a/src/util.ts b/src/util.ts index a9b495491..4c2f58049 100644 --- a/src/util.ts +++ b/src/util.ts @@ -280,7 +280,7 @@ export class PassThroughShim extends PassThrough { * interfere with GCS filter string syntax. * * @param {FileMetadata} [metadata] The metadata object to validate. - * @returns {boolean} Returns `true` if valid, throws with error otherwise. + * @throws {Error} If a context key or value contains a double quote. */ export function validateContexts(metadata?: FileMetadata) { const custom = metadata?.contexts?.custom; From 9a99c112eb6c3ce5a8f0a7126cafb6ec401991ee Mon Sep 17 00:00:00 2001 From: Thiyagu K Date: Fri, 6 Mar 2026 10:38:44 +0000 Subject: [PATCH 5/6] Revert "fix comments" This reverts commit fa9586ab15b6dc2c62bf04c17f5b69afa9798813. --- src/file.ts | 5 ++--- src/util.ts | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/file.ts b/src/file.ts index c8b4ef375..793580711 100644 --- a/src/file.ts +++ b/src/file.ts @@ -497,7 +497,7 @@ export interface FileMetadata extends BaseMetadata { }; contexts?: { custom: { - [key: string]: ContextValue | null; + [key: string]: ContextValue; } | null; }; customTime?: string; @@ -1313,9 +1313,8 @@ class File extends ServiceObject { try { validateContexts({contexts: options.contexts}); } catch (err) { - if (callback) { + if (callback) return (callback as CopyCallback)(err as Error, null, null); - } return Promise.reject(err); } } diff --git a/src/util.ts b/src/util.ts index 4c2f58049..a9b495491 100644 --- a/src/util.ts +++ b/src/util.ts @@ -280,7 +280,7 @@ export class PassThroughShim extends PassThrough { * interfere with GCS filter string syntax. * * @param {FileMetadata} [metadata] The metadata object to validate. - * @throws {Error} If a context key or value contains a double quote. + * @returns {boolean} Returns `true` if valid, throws with error otherwise. */ export function validateContexts(metadata?: FileMetadata) { const custom = metadata?.contexts?.custom; From 75b1f9c7eda4aeef3bc7a8daac1b50ec43d6d91e Mon Sep 17 00:00:00 2001 From: Thiyagu K Date: Fri, 6 Mar 2026 10:53:35 +0000 Subject: [PATCH 6/6] lint fix --- src/file.ts | 5 +++-- src/util.ts | 4 ++-- system-test/storage.ts | 12 +++++------- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/src/file.ts b/src/file.ts index 793580711..c8b4ef375 100644 --- a/src/file.ts +++ b/src/file.ts @@ -497,7 +497,7 @@ export interface FileMetadata extends BaseMetadata { }; contexts?: { custom: { - [key: string]: ContextValue; + [key: string]: ContextValue | null; } | null; }; customTime?: string; @@ -1313,8 +1313,9 @@ class File extends ServiceObject { try { validateContexts({contexts: options.contexts}); } catch (err) { - if (callback) + if (callback) { return (callback as CopyCallback)(err as Error, null, null); + } return Promise.reject(err); } } diff --git a/src/util.ts b/src/util.ts index a9b495491..b86ba3002 100644 --- a/src/util.ts +++ b/src/util.ts @@ -280,9 +280,9 @@ export class PassThroughShim extends PassThrough { * interfere with GCS filter string syntax. * * @param {FileMetadata} [metadata] The metadata object to validate. - * @returns {boolean} Returns `true` if valid, throws with error otherwise. + * @returns {void} Throws an error if validation fails. */ -export function validateContexts(metadata?: FileMetadata) { +export function validateContexts(metadata?: FileMetadata): void { const custom = metadata?.contexts?.custom; if (!custom) return; diff --git a/system-test/storage.ts b/system-test/storage.ts index c285ba59b..2c33321a7 100644 --- a/system-test/storage.ts +++ b/system-test/storage.ts @@ -3556,7 +3556,7 @@ describe('storage', function () { const [metadata] = await file.getMetadata(); assert.ok(metadata.contexts?.custom); assert.strictEqual( - metadata.contexts.custom['team-owner'].value, + metadata.contexts.custom['team-owner']?.value, 'storage-team' ); assert.ok(metadata.contexts.custom['team-owner'].createTime); @@ -3570,13 +3570,12 @@ describe('storage', function () { }, }, }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await file.setMetadata(patchMetadata as any); + await file.setMetadata(patchMetadata); const [updatedMetadata] = await file.getMetadata(); const finalCustom = updatedMetadata.contexts!.custom!; - assert.strictEqual(finalCustom['priority'].value, 'critical'); - assert.strictEqual(finalCustom['env'].value, 'prod'); + assert.strictEqual(finalCustom['priority']?.value, 'critical'); + assert.strictEqual(finalCustom['env']?.value, 'prod'); assert.strictEqual(finalCustom['team-owner'], undefined); assert.ok(finalCustom['priority'].updateTime); }); @@ -3613,8 +3612,7 @@ describe('storage', function () { contexts: { custom: null, }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any); + }); const [metadata] = await file.getMetadata(); assert.strictEqual(metadata.contexts?.custom, undefined);