diff --git a/packages/grpc-js/src/call-credentials.ts b/packages/grpc-js/src/call-credentials.ts index a9afe4ae3..fa1c8b92d 100644 --- a/packages/grpc-js/src/call-credentials.ts +++ b/packages/grpc-js/src/call-credentials.ts @@ -162,6 +162,9 @@ class ComposedCallCredentials extends CallCredentials { } compose(other: CallCredentials): CallCredentials { + if (other instanceof EmptyCallCredentials) { + return this; + } return new ComposedCallCredentials(this.creds.concat([other])); } @@ -197,6 +200,9 @@ class SingleCallCredentials extends CallCredentials { } compose(other: CallCredentials): CallCredentials { + if (other instanceof EmptyCallCredentials) { + return this; + } return new ComposedCallCredentials([this, other]); } @@ -225,3 +231,9 @@ class EmptyCallCredentials extends CallCredentials { return other instanceof EmptyCallCredentials; } } + +export function isEmptyCallCredentials( + callCredentials: CallCredentials +): boolean { + return callCredentials instanceof EmptyCallCredentials; +} diff --git a/packages/grpc-js/src/channel-credentials.ts b/packages/grpc-js/src/channel-credentials.ts index a6ded81ea..04b162899 100644 --- a/packages/grpc-js/src/channel-credentials.ts +++ b/packages/grpc-js/src/channel-credentials.ts @@ -184,6 +184,7 @@ class InsecureChannelCredentialsImpl extends ChannelCredentials { return other instanceof InsecureChannelCredentialsImpl; } _createSecureConnector(channelTarget: GrpcUri, options: ChannelOptions, callCredentials?: CallCredentials): SecureConnector { + const credentials = callCredentials ?? CallCredentials.createEmpty(); return { connect(socket) { return Promise.resolve({ @@ -195,7 +196,7 @@ class InsecureChannelCredentialsImpl extends ChannelCredentials { return Promise.resolve(); }, getCallCredentials: () => { - return callCredentials ?? CallCredentials.createEmpty(); + return credentials; }, destroy() {} } diff --git a/packages/grpc-js/src/load-balancing-call.ts b/packages/grpc-js/src/load-balancing-call.ts index 75b1d3e45..cd263dbe7 100644 --- a/packages/grpc-js/src/load-balancing-call.ts +++ b/packages/grpc-js/src/load-balancing-call.ts @@ -15,7 +15,7 @@ * */ -import { CallCredentials } from './call-credentials'; +import { CallCredentials, isEmptyCallCredentials } from './call-credentials'; import { Call, DeadlineInfoProvider, @@ -38,6 +38,8 @@ import { AuthContext } from './auth-context'; import { SubchannelInterface } from './subchannel-interface'; const TRACER_NAME = 'load_balancing_call'; +const RESOLVED_EMPTY_METADATA: Promise = + Promise.resolve(undefined); export type RpcProgress = 'NOT_STARTED' | 'DROP' | 'REFUSED' | 'PROCESSED'; @@ -139,6 +141,17 @@ export class LoadBalancingCall implements Call, DeadlineInfoProvider { } } + private generateCallCredentialsMetadata( + callCredentials: CallCredentials + ): Promise { + return isEmptyCallCredentials(callCredentials) + ? RESOLVED_EMPTY_METADATA + : callCredentials.generateMetadata({ + method_name: this.methodName, + service_url: this.serviceUrl, + }); + } + doPick() { if (this.ended) { return; @@ -167,9 +180,9 @@ export class LoadBalancingCall implements Call, DeadlineInfoProvider { } switch (pickResult.pickResultType) { case PickResultType.COMPLETE: - const combinedCallCredentials = this.credentials.compose(pickResult.subchannel!.getCallCredentials()); - combinedCallCredentials - .generateMetadata({ method_name: this.methodName, service_url: this.serviceUrl }) + this.generateCallCredentialsMetadata( + this.credentials.compose(pickResult.subchannel!.getCallCredentials()) + ) .then( credsMetadata => { /* If this call was cancelled (e.g. by the deadline) before @@ -181,7 +194,9 @@ export class LoadBalancingCall implements Call, DeadlineInfoProvider { ); return; } - finalMetadata.merge(credsMetadata); + if (credsMetadata) { + finalMetadata.merge(credsMetadata); + } if (finalMetadata.get('authorization').length > 1) { this.outputStatus( { diff --git a/packages/grpc-js/test/test-call-credentials.ts b/packages/grpc-js/test/test-call-credentials.ts index 0a6827274..145d52a1f 100644 --- a/packages/grpc-js/test/test-call-credentials.ts +++ b/packages/grpc-js/test/test-call-credentials.ts @@ -20,8 +20,14 @@ import * as assert from 'assert'; import { CallCredentials, CallMetadataGenerator, + isEmptyCallCredentials, } from '../src/call-credentials'; +import { ConnectivityState } from '../src/connectivity-state'; +import { Status } from '../src/constants'; +import { LoadBalancingCall } from '../src/load-balancing-call'; import { Metadata } from '../src/metadata'; +import { PickResultType } from '../src/picker'; +import { computeServiceUrl } from '../src/uri-parser'; // Metadata generators @@ -52,6 +58,37 @@ describe('CallCredentials', () => { }); }); + describe('isEmptyCallCredentials', () => { + it('should return true for empty call credentials', () => { + const emptyCredentials = CallCredentials.createEmpty(); + assert.strictEqual(isEmptyCallCredentials(emptyCredentials), true); + assert.strictEqual( + isEmptyCallCredentials(emptyCredentials.compose(emptyCredentials)), + true + ); + }); + + it('should return false for non-empty call credentials', () => { + const callCredentials1 = CallCredentials.createFromMetadataGenerator( + generateFromServiceURL + ); + const callCredentials2 = CallCredentials.createFromMetadataGenerator( + generateFromServiceURL + ); + assert.strictEqual(isEmptyCallCredentials(callCredentials1), false); + assert.strictEqual( + isEmptyCallCredentials( + callCredentials1.compose(CallCredentials.createEmpty()) + ), + false + ); + assert.strictEqual( + isEmptyCallCredentials(callCredentials1.compose(callCredentials2)), + false + ); + }); + }); + describe('compose', () => { it('should accept a CallCredentials object and return a new object', () => { const callCredentials1 = CallCredentials.createFromMetadataGenerator( @@ -65,6 +102,44 @@ describe('CallCredentials', () => { assert.notStrictEqual(combinedCredentials, callCredentials2); }); + it('should return the same object when composed with empty credentials', async () => { + const callCredentials1 = CallCredentials.createFromMetadataGenerator( + generateFromServiceURL + ); + const callCredentials2 = CallCredentials.createFromMetadataGenerator( + generateFromServiceURL + ); + const emptyCredentials = CallCredentials.createEmpty(); + assert.strictEqual( + emptyCredentials.compose(emptyCredentials), + emptyCredentials + ); + assert.strictEqual( + callCredentials1.compose(emptyCredentials), + callCredentials1 + ); + assert.strictEqual( + emptyCredentials.compose(callCredentials1), + callCredentials1 + ); + const combinedCredentials = callCredentials1.compose(callCredentials2); + assert.strictEqual( + combinedCredentials.compose(emptyCredentials), + combinedCredentials + ); + assert.strictEqual( + emptyCredentials.compose(combinedCredentials), + combinedCredentials + ); + const metadata = await callCredentials1 + .compose(emptyCredentials) + .generateMetadata({ + method_name: 'bar', + service_url: 'foo', + }); + assert.deepStrictEqual(metadata.get('service_url'), ['foo']); + }); + it('should be chainable', () => { const callCredentials1 = CallCredentials.createFromMetadataGenerator( generateFromServiceURL @@ -149,4 +224,111 @@ describe('CallCredentials', () => { ); }); }); + + describe('LoadBalancingCall integration', () => { + function runLoadBalancingCallPick( + callCredentials: CallCredentials, + subchannelCredentials: CallCredentials, + initialMetadata: Metadata + ): Promise { + return new Promise((resolve, reject) => { + const mockSubchannel: any = { + getCallCredentials: () => subchannelCredentials, + getConnectivityState: () => ConnectivityState.READY, + getRealSubchannel: () => mockSubchannel, + getChannelzRef: () => ({ id: 1 }), + getAddress: () => 'localhost:12345', + createCall: (metadata: Metadata) => { + resolve(metadata); + return { + getCallNumber: () => 1, + startRead: () => {}, + sendMessageWithContext: () => {}, + halfClose: () => {}, + cancelWithStatus: () => {}, + }; + }, + }; + const mockChannel: any = { + getServiceUrl: (host: string, methodName: string) => + computeServiceUrl(host, methodName), + doPick: () => ({ + pickResultType: PickResultType.COMPLETE, + subchannel: mockSubchannel, + status: null, + onCallStarted: null, + onCallEnded: null, + }), + }; + const callConfig: any = { + methodConfig: { name: [] }, + pickInformation: {}, + status: Status.OK, + dynamicFilterFactories: [], + }; + const call = new LoadBalancingCall( + mockChannel, + callConfig, + '/service/method', + 'localhost:12345', + callCredentials, + Infinity, + 1 + ); + call.start(initialMetadata, { + onReceiveMetadata: () => {}, + onReceiveMessage: () => {}, + onReceiveStatus: status => { + reject(new Error(`Unexpected status: ${status.details}`)); + }, + }); + }); + } + + it('should bypass generateMetadata when credentials are empty', async () => { + const emptyPrototype = Object.getPrototypeOf( + CallCredentials.createEmpty() + ); + const originalGenerateMetadata = emptyPrototype.generateMetadata; + let generateMetadataCalls = 0; + emptyPrototype.generateMetadata = function ( + ...args: Parameters + ) { + generateMetadataCalls += 1; + return originalGenerateMetadata.apply(this, args); + }; + try { + const initialMetadata = new Metadata(); + initialMetadata.set('custom-key', 'custom-value'); + const finalMetadata = await runLoadBalancingCallPick( + CallCredentials.createEmpty(), + CallCredentials.createEmpty(), + initialMetadata + ); + assert.strictEqual(generateMetadataCalls, 0); + assert.deepStrictEqual(finalMetadata.get('custom-key'), [ + 'custom-value', + ]); + } finally { + emptyPrototype.generateMetadata = originalGenerateMetadata; + } + }); + + it('should call generateMetadata and merge metadata when credentials are non-empty', async () => { + const initialMetadata = new Metadata(); + initialMetadata.set('custom-key', 'custom-value'); + const callCredentials = CallCredentials.createFromMetadataGenerator( + generateFromServiceURL + ); + const finalMetadata = await runLoadBalancingCallPick( + callCredentials, + CallCredentials.createEmpty(), + initialMetadata + ); + assert.deepStrictEqual(finalMetadata.get('custom-key'), ['custom-value']); + assert.deepStrictEqual(finalMetadata.get('service_url'), [ + 'https://localhost/service', + ]); + }); + }); }); diff --git a/packages/grpc-js/test/test-channel-credentials.ts b/packages/grpc-js/test/test-channel-credentials.ts index 00ab08778..c439821d7 100644 --- a/packages/grpc-js/test/test-channel-credentials.ts +++ b/packages/grpc-js/test/test-channel-credentials.ts @@ -91,6 +91,35 @@ describe('ChannelCredentials Implementation', () => { const composedChannelCreds = channelCreds.compose(callCreds); assert.ok(composedChannelCreds instanceof ChannelCredentials); }); + + it('should preserve unwrapped CallCredentials when creating a connector', () => { + const channelCreds = ChannelCredentials.createSsl(); + const callCreds = CallCredentials.createFromMetadataGenerator( + (options, cb) => cb(null, new grpc.Metadata()) + ); + const composedChannelCreds = channelCreds.compose(callCreds); + const connector = composedChannelCreds._createSecureConnector( + { scheme: 'dns', path: 'localhost' }, + {} + ); + assert.strictEqual(connector.getCallCredentials(), callCreds); + connector.destroy(); + }); + }); + + describe('createInsecure', () => { + it('should return the same default CallCredentials instance from a connector', () => { + const insecureCreds = ChannelCredentials.createInsecure(); + const connector = insecureCreds._createSecureConnector( + { scheme: 'dns', path: 'localhost' }, + {} + ); + assert.strictEqual( + connector.getCallCredentials(), + connector.getCallCredentials() + ); + connector.destroy(); + }); }); });