Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion handwritten/spanner/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,6 @@
"@google-cloud/monitoring": "^5.0.0",
"@google-cloud/opentelemetry-resource-util": "^2.4.0",
"@google-cloud/precise-date": "^5.0.0",
"@google-cloud/projectify": "^5.0.0",
"@google-cloud/promisify": "^5.0.0",
"@grpc/grpc-js": "^1.13.2",
"@grpc/proto-loader": "^0.8.0",
Expand Down
2 changes: 1 addition & 1 deletion handwritten/spanner/src/common-grpc/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import {
ServiceConfig,
util,
} from '@google-cloud/common';
import {replaceProjectIdToken} from '@google-cloud/projectify';
import {replaceProjectIdToken} from '../helper';
import {
loadSync,
PackageDefinition,
Expand Down
89 changes: 89 additions & 0 deletions handwritten/spanner/src/helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
*/

import {grpc} from 'google-gax';
import {Stream} from 'stream';
/**
* Checks whether the given error is a 'Database not found' error.
* @param {Error} error The error to check.
Expand Down Expand Up @@ -290,3 +291,91 @@ export function isUuid(value: any): boolean {
)
);
}

const PROJECT_ID_TOKEN = '{{projectId}}';
const PROJECT_ID_TOKEN_REGEX = /{{projectId}}/g;

// Whitelisted request properties that contain, or lead to, resource name strings with project ID placeholders.
const KEYS_TO_SCAN = new Set([
'database',
'session',
'name',
'parent',
'instance',
'resource',
'backup',
'instanceConfig',
'baseConfig',
'kmsKeyName',
// Intermediate object keys that must be traversed to reach the above leaves
'config',
'encryptionConfig',
]);

/**
* Populate the `{{projectId}}` placeholder.
*
* @throws {Error} If a projectId is required, but one is not provided.
*
* @param {*} - Any input value that may contain a placeholder. Arrays and objects will be looped.
* @param {string} projectId - A projectId. If not provided
* @return {*} - The original argument with all placeholders populated.
*/
export function replaceProjectIdToken(value: any, projectId: string): any {
if (typeof value === 'string') {
if (value.includes(PROJECT_ID_TOKEN)) {
if (!projectId || projectId === PROJECT_ID_TOKEN) {
throw new MissingProjectIdError();
}
return value.replace(PROJECT_ID_TOKEN_REGEX, projectId);
}
return value;
}

if (value === null || typeof value !== 'object') {
return value;
}

if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) {
const original = value[i];
const processed = replaceProjectIdToken(original, projectId);
if (processed !== original) {
value[i] = processed;
}
}
return value;
}

if (value instanceof Buffer || value instanceof Stream || isDate(value)) {
return value;
}

for (const key in value) {
if (Object.prototype.hasOwnProperty.call(value, key)) {
if (!KEYS_TO_SCAN.has(key)) {
continue;
}
const original = value[key];
const processed = replaceProjectIdToken(original, projectId);
if (processed !== original) {
value[key] = processed;
}
}
}

return value;
}

/**
* Custom error type for missing project ID errors.
*/
class MissingProjectIdError extends Error {
constructor() {
super(
`Sorry, we cannot connect to Cloud Services without a project
ID. You may specify one with an environment variable named
"GOOGLE_CLOUD_PROJECT".`.replace(/ +/g, ' '),
);
}
}
2 changes: 1 addition & 1 deletion handwritten/spanner/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

import {GrpcService, GrpcServiceConfig} from './common-grpc/service';
import {PreciseDate} from '@google-cloud/precise-date';
import {replaceProjectIdToken} from '@google-cloud/projectify';
import {replaceProjectIdToken} from './helper';
import {promisifyAll} from '@google-cloud/promisify';
import * as extend from 'extend';
import {GoogleAuth, GoogleAuthOptions} from 'google-auth-library';
Expand Down
5 changes: 2 additions & 3 deletions handwritten/spanner/test/common/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@

import * as path from 'path';
import {util} from '@google-cloud/common';
import {replaceProjectIdToken} from '@google-cloud/projectify';
import * as grpcProtoLoader from '@grpc/proto-loader';
import * as assert from 'assert';
import {before, beforeEach, after, afterEach, describe, it} from 'mocha';
Expand All @@ -25,7 +24,7 @@ import * as proxyquire from 'proxyquire';
import * as retryRequest from 'retry-request';
import * as sn from 'sinon';
import {PassThrough} from 'stream';
import {isDate} from '../../src/helper';
import {isDate, replaceProjectIdToken} from '../../src/helper';

const sinon = sn.createSandbox();
const glob = global as {} as {GCLOUD_SANDBOX_ENV?: boolean | {}};
Expand Down Expand Up @@ -149,7 +148,7 @@ describe('GrpcService', () => {
Service: FakeService,
util: fakeUtil,
},
'@google-cloud/projectify': {
'../helper.js': {
replaceProjectIdToken: fakeReplaceProjectIdTokenOverride,
},
'@grpc/proto-loader': fakeGrpcProtoLoader,
Expand Down
212 changes: 212 additions & 0 deletions handwritten/spanner/test/helper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
/*!
* Copyright 2026 Google LLC. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import * as assert from 'assert';
import {describe, it} from 'mocha';
import {replaceProjectIdToken} from '../src/helper';
import {Stream} from 'stream';

describe('helper', () => {
describe('replaceProjectIdToken', () => {
const projectId = 'my-project-id';

it('should replace placeholders in simple strings', () => {
const input = 'projects/{{projectId}}/instances';
const expected = 'projects/my-project-id/instances';
assert.strictEqual(replaceProjectIdToken(input, projectId), expected);
});

it('should return non-placeholder strings as is', () => {
const input = 'projects/fixed-project/instances';
assert.strictEqual(replaceProjectIdToken(input, projectId), input);
});

it('should replace placeholders inside arrays', () => {
const input = ['projects/{{projectId}}', 'fixed-string'];
const expected = ['projects/my-project-id', 'fixed-string'];
assert.deepStrictEqual(replaceProjectIdToken(input, projectId), expected);
});

it('should replace placeholders recursively in objects', () => {
const input = {
resource: 'projects/{{projectId}}/instances',
config: {
parent: 'projects/{{projectId}}',
},
};
const expected = {
resource: 'projects/my-project-id/instances',
config: {
parent: 'projects/my-project-id',
},
};
assert.deepStrictEqual(replaceProjectIdToken(input, projectId), expected);
});

it('should completely skip recursive traversal for known user data keys', () => {
const input = {
session: 'projects/{{projectId}}/sessions',
sql: 'SELECT * FROM Users WHERE parent = "{{projectId}}"',
params: {
id: '{{projectId}}',
deep: {
key: 'projects/{{projectId}}',
},
},
mutations: [
{
insert: {
table: 'Users',
values: ['{{projectId}}'],
},
},
],
};
const expected = {
session: 'projects/my-project-id/sessions',
sql: 'SELECT * FROM Users WHERE parent = "{{projectId}}"', // sql key skipped
params: {
id: '{{projectId}}', // params key skipped
deep: {
key: 'projects/{{projectId}}',
},
},
mutations: [
// mutations key skipped
{
insert: {
table: 'Users',
values: ['{{projectId}}'],
},
},
],
};
assert.deepStrictEqual(replaceProjectIdToken(input, projectId), expected);
});

it('should return Buffers and Streams as-is', () => {
const buffer = Buffer.from('test');
const stream = new Stream();
assert.strictEqual(replaceProjectIdToken(buffer, projectId), buffer);
assert.strictEqual(replaceProjectIdToken(stream, projectId), stream);
});

it('should not throw when frozen object does not have a placeholder', () => {
const frozenObj = Object.freeze({
name: 'projects/fixed-project',
fixed: 'no-placeholder',
nested: Object.freeze({
other: 'fixed-value',
}),
});

assert.doesNotThrow(() => {
replaceProjectIdToken(frozenObj, projectId);
});
});

it('should throw if a frozen object contains a placeholder because mutation is illegal', () => {
const frozenObj = Object.freeze({
name: 'projects/{{projectId}}',
});

assert.throws(() => {
replaceProjectIdToken(frozenObj, projectId);
}, /Cannot assign to read only property/);
});

it('should replace more than one {{projectId}}', () => {
const input = 'A {{projectId}} M {{projectId}} Z';
const expected = 'A my-project-id M my-project-id Z';
assert.strictEqual(replaceProjectIdToken(input, projectId), expected);
});

it('should replace any {{projectId}} it finds (nested / complex tree)', () => {
const input = {
parent: 'A {{projectId}} Z',
database: {
parent: 'A {{projectId}} Z',
baseConfig: 'projects/{{projectId}}/instanceConfigs/base-1',
config: {
name: 'A {{projectId}} Z',
},
},
backup: [
{
name: 'A {{projectId}} Z',
encryptionConfig: {
kmsKeyName: 'A {{projectId}} Z',
},
database: [
{
session: 'A {{projectId}} Z',
parent: {
name: 'A {{projectId}} Z',
},
},
],
},
],
};
const expected = {
parent: 'A my-project-id Z',
database: {
parent: 'A my-project-id Z',
baseConfig: 'projects/my-project-id/instanceConfigs/base-1',
config: {
name: 'A my-project-id Z',
},
},
backup: [
{
name: 'A my-project-id Z',
encryptionConfig: {
kmsKeyName: 'A my-project-id Z',
},
database: [
{
session: 'A my-project-id Z',
parent: {
name: 'A my-project-id Z',
},
},
],
},
],
};
assert.deepStrictEqual(replaceProjectIdToken(input, projectId), expected);
});

it('should not inject projectId into stream properties', () => {
const transform = new Stream() as any;
transform.prop = 'A {{projectId}} Z';

const replaced = replaceProjectIdToken(transform, projectId);
assert.deepStrictEqual(transform.prop, replaced.prop);
});

it('should throw MissingProjectIdError if projectId is falsy or is the placeholder itself', () => {
const input = 'projects/{{projectId}}/instances';
assert.throws(() => {
replaceProjectIdToken(input, '');
}, /Sorry, we cannot connect to Cloud Services/);

assert.throws(() => {
replaceProjectIdToken(input, '{{projectId}}');
}, /Sorry, we cannot connect to Cloud Services/);
});
});
});
4 changes: 2 additions & 2 deletions handwritten/spanner/test/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import * as proxyquire from 'proxyquire';
import * as through from 'through2';
import {util} from '@google-cloud/common';
import {PreciseDate} from '@google-cloud/precise-date';
import {replaceProjectIdToken} from '@google-cloud/projectify';
import {replaceProjectIdToken} from '../src/helper';
import * as pfy from '@google-cloud/promisify';
import {grpc} from 'google-gax';
import * as sinon from 'sinon';
Expand Down Expand Up @@ -190,7 +190,7 @@ describe('Spanner', () => {
GrpcService: FakeGrpcService,
},
'@google-cloud/promisify': fakePfy,
'@google-cloud/projectify': {
'./helper.js': {
replaceProjectIdToken: fakeReplaceProjectIdToken,
},
'google-auth-library': {
Expand Down
Loading