Skip to content
Merged
6 changes: 5 additions & 1 deletion src/cmap/connection_pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -610,13 +610,17 @@ export class ConnectionPool extends TypedEventEmitter<ConnectionPoolEvents> {
}

private createConnection(callback: Callback<Connection>) {
// Note that metadata and extendedMetadata may have changed on the client but have
// been frozen here, so we pull the extendedMetadata promise always from the client
// no mattter what options were set at the construction of the pool.
const connectOptions: ConnectionOptions = {
...this.options,
id: this.connectionCounter.next().value,
generation: this.generation,
cancellationToken: this.cancellationToken,
mongoLogger: this.mongoLogger,
authProviders: this.server.topology.client.s.authProviders
authProviders: this.server.topology.client.s.authProviders,
extendedMetadata: this.server.topology.client.options.extendedMetadata
};

this.pending++;
Expand Down
29 changes: 27 additions & 2 deletions src/cmap/handshake/client_metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as os from 'os';
import * as process from 'process';

import { BSON, type Document, Int32 } from '../../bson';
import { MongoInvalidArgumentError } from '../../error';
import { MongoInvalidArgumentError, MongoRuntimeError } from '../../error';
import type { MongoOptions } from '../../mongo_client';
import { fileIsAccessible } from '../../utils';

Expand Down Expand Up @@ -90,7 +90,10 @@ export class LimitedSizeDocument {
}
}

type MakeClientMetadataOptions = Pick<MongoOptions, 'appName' | 'driverInfo'>;
type MakeClientMetadataOptions = Pick<
MongoOptions,
'appName' | 'driverInfo' | 'additionalDriverInfo'
>;
/**
* From the specs:
* Implementors SHOULD cumulatively update fields in the following order until the document is under the size limit:
Expand Down Expand Up @@ -119,6 +122,22 @@ export function makeClientMetadata(options: MakeClientMetadataOptions): ClientMe
version: version.length > 0 ? `${NODE_DRIVER_VERSION}|${version}` : NODE_DRIVER_VERSION
};

if (options.additionalDriverInfo == null) {
throw new MongoRuntimeError(
'Client options `additionalDriverInfo` must always default to an empty array'
);
}

// This is where we handle additional driver info added after client construction.
for (const { name: n = '', version: v = '' } of options.additionalDriverInfo) {
if (n.length > 0) {
driverInfo.name = `${driverInfo.name}|${n}`;
}
if (v.length > 0) {
driverInfo.version = `${driverInfo.version}|${v}`;
}
}

if (!metadataDocument.ifItFitsItSits('driver', driverInfo)) {
throw new MongoInvalidArgumentError(
'Unable to include driverInfo name and version, metadata cannot exceed 512 bytes'
Expand All @@ -130,6 +149,12 @@ export function makeClientMetadata(options: MakeClientMetadataOptions): ClientMe
runtimeInfo = `${runtimeInfo}|${platform}`;
}

for (const { platform: p = '' } of options.additionalDriverInfo) {
if (p.length > 0) {
runtimeInfo = `${runtimeInfo}|${p}`;
}
}

if (!metadataDocument.ifItFitsItSits('platform', runtimeInfo)) {
throw new MongoInvalidArgumentError(
'Unable to include driverInfo platform, metadata cannot exceed 512 bytes'
Expand Down
3 changes: 3 additions & 0 deletions src/connection_string.ts
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,9 @@ export function parseOptions(
}
);

// Set the default for the additional driver info.
mongoOptions.additionalDriverInfo = [];

mongoOptions.metadata = makeClientMetadata(mongoOptions);

mongoOptions.extendedMetadata = addContainerMetadata(mongoOptions.metadata).then(
Expand Down
46 changes: 42 additions & 4 deletions src/mongo_client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@ import { type TokenCache } from './cmap/auth/mongodb_oidc/token_cache';
import { AuthMechanism } from './cmap/auth/providers';
import type { LEGAL_TCP_SOCKET_OPTIONS, LEGAL_TLS_SOCKET_OPTIONS } from './cmap/connect';
import type { Connection } from './cmap/connection';
import type { ClientMetadata } from './cmap/handshake/client_metadata';
import {
addContainerMetadata,
type ClientMetadata,
makeClientMetadata
} from './cmap/handshake/client_metadata';
import type { CompressorName } from './cmap/wire_protocol/compression';
import { parseOptions, resolveSRVRecord } from './connection_string';
import { MONGO_CLIENT_EVENTS } from './constants';
Expand Down Expand Up @@ -398,9 +402,31 @@ export class MongoClient extends TypedEventEmitter<MongoClientEvents> implements
* The consolidate, parsed, transformed and merged options.
*/
public readonly options: Readonly<
Omit<MongoOptions, 'monitorCommands' | 'ca' | 'crl' | 'key' | 'cert'>
Omit<
MongoOptions,
| 'monitorCommands'
| 'ca'
| 'crl'
| 'key'
| 'cert'
| 'driverInfo'
| 'additionalDriverInfo'
| 'metadata'
| 'extendedMetadata'
>
> &
Pick<MongoOptions, 'monitorCommands' | 'ca' | 'crl' | 'key' | 'cert'>;
Pick<
MongoOptions,
| 'monitorCommands'
| 'ca'
| 'crl'
| 'key'
| 'cert'
| 'driverInfo'
| 'additionalDriverInfo'
| 'metadata'
| 'extendedMetadata'
>;

constructor(url: string, options?: MongoClientOptions) {
super();
Expand Down Expand Up @@ -459,6 +485,18 @@ export class MongoClient extends TypedEventEmitter<MongoClientEvents> implements
await this.close();
}

/**
* Append metadata to the client metadata after instantiation.
* @param driverInfo - Information about the application or library.
*/
appendMetadata(driverInfo: DriverInfo) {
this.options.additionalDriverInfo.push(driverInfo);
this.options.metadata = makeClientMetadata(this.options);
this.options.extendedMetadata = addContainerMetadata(this.options.metadata)
.then(undefined, squashError)
.then(result => result ?? {}); // ensure Promise<Document>
}

/** @internal */
private checkForNonGenuineHosts() {
const documentDBHostnames = this.options.hosts.filter((hostAddress: HostAddress) =>
Expand Down Expand Up @@ -1041,8 +1079,8 @@ export interface MongoOptions
dbName: string;
/** @deprecated - Will be made internal in a future major release. */
metadata: ClientMetadata;
/** @internal */
extendedMetadata: Promise<Document>;
additionalDriverInfo: DriverInfo[];
/** @internal */
autoEncrypter?: AutoEncrypter;
/** @internal */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,10 @@ describe('Connection', function () {
...commonConnectOptions,
connectionType: Connection,
...this.configuration.options,
metadata: makeClientMetadata({ driverInfo: {} }),
extendedMetadata: addContainerMetadata(makeClientMetadata({ driverInfo: {} }))
metadata: makeClientMetadata({ driverInfo: {}, additionalDriverInfo: [] }),
extendedMetadata: addContainerMetadata(
makeClientMetadata({ driverInfo: {}, additionalDriverInfo: [] })
)
};

let conn;
Expand All @@ -72,8 +74,10 @@ describe('Connection', function () {
connectionType: Connection,
...this.configuration.options,
monitorCommands: true,
metadata: makeClientMetadata({ driverInfo: {} }),
extendedMetadata: addContainerMetadata(makeClientMetadata({ driverInfo: {} }))
metadata: makeClientMetadata({ driverInfo: {}, additionalDriverInfo: [] }),
extendedMetadata: addContainerMetadata(
makeClientMetadata({ driverInfo: {}, additionalDriverInfo: [] })
)
};

let conn;
Expand Down Expand Up @@ -104,8 +108,10 @@ describe('Connection', function () {
connectionType: Connection,
...this.configuration.options,
monitorCommands: true,
metadata: makeClientMetadata({ driverInfo: {} }),
extendedMetadata: addContainerMetadata(makeClientMetadata({ driverInfo: {} }))
metadata: makeClientMetadata({ driverInfo: {}, additionalDriverInfo: [] }),
extendedMetadata: addContainerMetadata(
makeClientMetadata({ driverInfo: {}, additionalDriverInfo: [] })
)
};

let conn;
Expand Down
159 changes: 159 additions & 0 deletions test/integration/mongodb-handshake/mongodb-handshake.prose.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
LEGACY_HELLO_COMMAND,
type MongoClient
} from '../../mongodb';
import { sleep } from '../../tools/utils';

type EnvironmentVariables = Array<[string, string]>;

Expand Down Expand Up @@ -194,3 +195,161 @@ describe('Handshake Prose Tests', function () {
});
});
});

describe('Client Metadata Update Prose Tests', function () {
let client: MongoClient;

afterEach(async function () {
await client?.close();
sinon.restore();
});

describe('Test 1: Test that the driver updates metadata', function () {
let initialClientMetadata;
let updatedClientMetadata;

const tests = [
{ testCase: 1, name: 'framework', version: '2.0', platform: 'Framework Platform' },
{ testCase: 2, name: 'framework', version: '2.0' },
{ testCase: 3, name: 'framework', platform: 'Framework Platform' },
{ testCase: 4, name: 'framework' }
];

for (const { testCase, name, version, platform } of tests) {
context(`Case: ${testCase}`, function () {
// 1. Create a `MongoClient` instance with the following:
// - `maxIdleTimeMS` set to `1ms`
// - Wrapping library metadata:
// | Field | Value |
// | -------- | ---------------- |
// | name | library |
// | version | 1.2 |
// | platform | Library Platform |
// 2. Send a `ping` command to the server and verify that the command succeeds.
// 3. Save intercepted `client` document as `initialClientMetadata`.
// 4. Wait 5ms for the connection to become idle.
beforeEach(async function () {
client = this.configuration.newClient(
{},
{
maxIdleTimeMS: 1,
driverInfo: { name: 'library', version: '1.2', platform: 'Library Platform' }
}
);

sinon.stub(Connection.prototype, 'command').callsFake(async function (ns, cmd, options) {
// @ts-expect-error: sinon will place wrappedMethod on the command method.
const command = Connection.prototype.command.wrappedMethod.bind(this);

if (cmd.hello || cmd[LEGACY_HELLO_COMMAND]) {
if (!initialClientMetadata) {
initialClientMetadata = cmd.client;
} else {
updatedClientMetadata = cmd.client;
}
}
return command(ns, cmd, options);
});

await client.db('test').command({ ping: 1 });
await sleep(5);
});

it('appends the metadata', async function () {
// 1. Append the `DriverInfoOptions` from the selected test case to the `MongoClient` metadata.
// 2. Send a `ping` command to the server and verify:
// - The command succeeds.
// - The framework metadata is appended to the existing `DriverInfoOptions` in the `client.driver` fields of the `hello`
// command, with values separated by a pipe `|`.
client.appendMetadata({ name, version, platform });
await client.db('test').command({ ping: 1 });

// Since we have our own driver metadata getting added, we really want to just
// assert that the last driver info values are appended at the end.
expect(updatedClientMetadata.driver.name).to.match(/^.*\|framework$/);
expect(updatedClientMetadata.driver.version).to.match(
new RegExp(`^.*\\|${version ? version : '1.2'}$`)
);
expect(updatedClientMetadata.platform).to.match(
new RegExp(`^.*\\|${platform ? platform : 'Library Platform'}$`)
);
// - All other subfields in the client document remain unchanged from initialClientMetadata.
// (Note os is the only one getting set in these tests)
expect(updatedClientMetadata.os).to.deep.equal(initialClientMetadata.os);
});
});
}
});

describe('Test 2: Multiple Successive Metadata Updates', function () {
let initialClientMetadata;
let updatedClientMetadata;

const tests = [
{ testCase: 1, name: 'framework', version: '2.0', platform: 'Framework Platform' },
{ testCase: 2, name: 'framework', version: '2.0' },
{ testCase: 3, name: 'framework', platform: 'Framework Platform' },
{ testCase: 4, name: 'framework' }
];

for (const { testCase, name, version, platform } of tests) {
context(`Case: ${testCase}`, function () {
// 1. Create a `MongoClient` instance with the following:
// - `maxIdleTimeMS` set to `1ms`
// 2. Append the following `DriverInfoOptions` to the `MongoClient` metadata:
// | Field | Value |
// | -------- | ---------------- |
// | name | library |
// | version | 1.2 |
// | platform | Library Platform |
// 3. Send a `ping` command to the server and verify that the command succeeds.
// 4. Save intercepted `client` document as `updatedClientMetadata`.
// 5. Wait 5ms for the connection to become idle.
beforeEach(async function () {
client = this.configuration.newClient({}, { maxIdleTimeMS: 1 });
client.appendMetadata({ name: 'library', version: '1.2', platform: 'Library Platform' });

sinon.stub(Connection.prototype, 'command').callsFake(async function (ns, cmd, options) {
// @ts-expect-error: sinon will place wrappedMethod on the command method.
const command = Connection.prototype.command.wrappedMethod.bind(this);

if (cmd.hello || cmd[LEGACY_HELLO_COMMAND]) {
if (!initialClientMetadata) {
initialClientMetadata = cmd.client;
} else {
updatedClientMetadata = cmd.client;
}
}
return command(ns, cmd, options);
});

await client.db('test').command({ ping: 1 });
await sleep(5);
});

it('appends the metadata', async function () {
// 1. Append the `DriverInfoOptions` from the selected test case to the `MongoClient` metadata.
// 2. Send a `ping` command to the server and verify:
// - The command succeeds.
// - The framework metadata is appended to the existing `DriverInfoOptions` in the `client.driver` fields of the `hello`
// command, with values separated by a pipe `|`.
client.appendMetadata({ name, version, platform });
await client.db('test').command({ ping: 1 });

// Since we have our own driver metadata getting added, we really want to just
// assert that the last driver info values are appended at the end.
expect(updatedClientMetadata.driver.name).to.match(/^.*\|framework$/);
expect(updatedClientMetadata.driver.version).to.match(
new RegExp(`^.*\\|${version ? version : '1.2'}$`)
);
expect(updatedClientMetadata.platform).to.match(
new RegExp(`^.*\\|${platform ? platform : 'Library Platform'}$`)
);
// - All other subfields in the client document remain unchanged from initialClientMetadata.
// (Note os is the only one getting set in these tests)
expect(updatedClientMetadata.os).to.deep.equal(initialClientMetadata.os);
});
});
}
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { join } from 'path';

import { loadSpecTests } from '../../spec';
import { runUnifiedSuite } from '../../tools/unified-spec-runner/runner';

describe('MongoDB Handshake Tests (Unified)', function () {
runUnifiedSuite(loadSpecTests(join('mongodb-handshake')));
});
Loading