From 7bd70a746920e94c6bb6e618cdabf3fc9da4eab7 Mon Sep 17 00:00:00 2001 From: Thiyagu K Date: Fri, 6 Mar 2026 14:07:25 +0000 Subject: [PATCH 1/2] 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. --- handwritten/storage/src/bucket.ts | 21 +- handwritten/storage/src/file.ts | 42 ++++ handwritten/storage/src/util.ts | 28 +++ handwritten/storage/system-test/storage.ts | 253 +++++++++++++++++++++ handwritten/storage/test/bucket.ts | 67 ++++++ handwritten/storage/test/file.ts | 203 +++++++++++++++++ 6 files changed, 613 insertions(+), 1 deletion(-) diff --git a/handwritten/storage/src/bucket.ts b/handwritten/storage/src/bucket.ts index fde1a9bfd18..18c6c7cd066 100644 --- a/handwritten/storage/src/bucket.ts +++ b/handwritten/storage/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/handwritten/storage/src/file.ts b/handwritten/storage/src/file.ts index 5c51e63dfb0..0934fb3c42a 100644 --- a/handwritten/storage/src/file.ts +++ b/handwritten/storage/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; @@ -469,6 +475,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 +495,11 @@ export interface FileMetadata extends BaseMetadata { encryptionAlgorithm?: string; keySha256?: string; }; + contexts?: { + custom: { + [key: string]: ContextValue | null; + } | null; + }; customTime?: string; eventBasedHold?: boolean | null; readonly eventBasedHoldReleaseTime?: string; @@ -1292,6 +1309,17 @@ 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; @@ -4127,6 +4155,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( @@ -4230,6 +4265,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/handwritten/storage/src/util.ts b/handwritten/storage/src/util.ts index 4957a210b6c..bbdfc0aa882 100644 --- a/handwritten/storage/src/util.ts +++ b/handwritten/storage/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,30 @@ 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 {void} Throws an error if validation fails. + */ +export function validateContexts(metadata?: FileMetadata): void { + 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/handwritten/storage/system-test/storage.ts b/handwritten/storage/system-test/storage.ts index 2f14fe01296..893bdac4583 100644 --- a/handwritten/storage/system-test/storage.ts +++ b/handwritten/storage/system-test/storage.ts @@ -3538,6 +3538,259 @@ 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 + }, + }, + }; + 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['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, + }, + }); + 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/handwritten/storage/test/bucket.ts b/handwritten/storage/test/bucket.ts index 5b49fa518d8..b284c5f3af9 100644 --- a/handwritten/storage/test/bucket.ts +++ b/handwritten/storage/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/handwritten/storage/test/file.ts b/handwritten/storage/test/file.ts index 14b2070aa10..311d5749582 100644 --- a/handwritten/storage/test/file.ts +++ b/handwritten/storage/test/file.ts @@ -5071,6 +5071,209 @@ 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(); + await file.setMetadata(patchMetadata); + + 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(); + await file.setMetadata(clearMetadata); + 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 b3c5bfbf42de07646eaff3dcab5c68f4f822963a Mon Sep 17 00:00:00 2001 From: Thiyagu K Date: Tue, 10 Mar 2026 09:02:22 +0000 Subject: [PATCH 2/2] feat(storage): implement Object Contexts with filtering and validation - Add support for Object Contexts metadata in `File` and `Bucket` operations. - Update `FileMetadata`, `CopyOptions`, and `CombineOptions` types to allow null values for context key deletion (PATCH semantics). - Refactor `validateContexts` to accept a `contexts` object directly for better consistency and simpler call patterns in `save`, `copy`, and `combine`. - Implement server-side list filtering support via the `filter` query parameter in `getFiles`, supporting NOT logic and existence wildcards. - Ensure metadata inheritance and explicit overrides work correctly during `File.copy` and `Bucket.combine`. - Add comprehensive unit and system tests covering CRUD, server-side operations, and complex filtering scenarios. --- handwritten/storage/src/bucket.ts | 2 +- handwritten/storage/src/file.ts | 6 +++--- handwritten/storage/src/util.ts | 7 +++---- handwritten/storage/test/bucket.ts | 4 ++-- 4 files changed, 9 insertions(+), 10 deletions(-) diff --git a/handwritten/storage/src/bucket.ts b/handwritten/storage/src/bucket.ts index 18c6c7cd066..0c493887bb1 100644 --- a/handwritten/storage/src/bucket.ts +++ b/handwritten/storage/src/bucket.ts @@ -1637,7 +1637,7 @@ class Bucket extends ServiceObject { if (options.contexts) { try { - validateContexts({contexts: options.contexts}); + validateContexts(options.contexts); } catch (err) { if (callback) { return (callback as CombineCallback)(err as Error, null, null); diff --git a/handwritten/storage/src/file.ts b/handwritten/storage/src/file.ts index 0934fb3c42a..247aedd78f5 100644 --- a/handwritten/storage/src/file.ts +++ b/handwritten/storage/src/file.ts @@ -1311,7 +1311,7 @@ class File extends ServiceObject { if (options.contexts) { try { - validateContexts({contexts: options.contexts}); + validateContexts(options.contexts); } catch (err) { if (callback) { return (callback as CopyCallback)(err as Error, null, null); @@ -4156,7 +4156,7 @@ class File extends ServiceObject { typeof optionsOrCallback === 'object' ? optionsOrCallback : {}; try { - validateContexts(options.metadata); + validateContexts(options.metadata?.contexts); } catch (err) { if (callback) return callback(err as Error); return Promise.reject(err); @@ -4266,7 +4266,7 @@ class File extends ServiceObject { : cb; try { - validateContexts(metadata); + validateContexts(metadata.contexts); } catch (err) { if (cb) return cb(err as Error); return Promise.reject(err); diff --git a/handwritten/storage/src/util.ts b/handwritten/storage/src/util.ts index bbdfc0aa882..1e1cc87b2b7 100644 --- a/handwritten/storage/src/util.ts +++ b/handwritten/storage/src/util.ts @@ -280,13 +280,12 @@ export class PassThroughShim extends PassThrough { * 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. + * @param {FileMetadata['contexts']} contexts The contexts object to validate. * @returns {void} Throws an error if validation fails. */ -export function validateContexts(metadata?: FileMetadata): void { - const custom = metadata?.contexts?.custom; +export function validateContexts(contexts?: FileMetadata['contexts']): void { + const custom = contexts?.custom; if (!custom) return; - for (const [key, context] of Object.entries(custom)) { if (key.includes('"')) { throw new Error( diff --git a/handwritten/storage/test/bucket.ts b/handwritten/storage/test/bucket.ts index b284c5f3af9..a2a622b3fd7 100644 --- a/handwritten/storage/test/bucket.ts +++ b/handwritten/storage/test/bucket.ts @@ -2020,7 +2020,7 @@ describe('Bucket', () => { }); it('should filter by absence of key/value pair (NOT)', done => { - const filter = 'NOT contexts."status"="active"'; + const filter = '-contexts."status"="active"'; bucket.request = (reqOpts: DecorateRequestOptions) => { assert.strictEqual(reqOpts.qs.filter, filter); done(); @@ -2040,7 +2040,7 @@ describe('Bucket', () => { }); it('should filter by absence of key regardless of value (Non-existence)', done => { - const filter = 'NOT contexts."status":*'; + const filter = '-contexts."status":*'; bucket.request = (reqOpts: DecorateRequestOptions) => { assert.strictEqual(reqOpts.qs.filter, filter); done();