From fc3f3e6ea6600a84ced9c69c52ae3637991265ed Mon Sep 17 00:00:00 2001 From: Matti Jauhiainen Date: Wed, 19 May 2021 23:20:43 +0800 Subject: [PATCH] Resolve SRV urls (#198) --- src/client.ts | 112 ++++++------------------------- src/cluster.ts | 133 +++++++++++++++++++++++++++++++++++++ src/database.ts | 19 +++--- src/types.ts | 57 ++++++++-------- src/utils/srv.ts | 117 ++++++++++++++++++++++++++++++++ src/utils/uri.ts | 66 ++++++++++++++++++- tests/cases/00_uri.ts | 68 ++++++++++++------- tests/cases/05_srv.ts | 150 ++++++++++++++++++++++++++++++++++++++++++ tests/test.ts | 3 + 9 files changed, 569 insertions(+), 156 deletions(-) create mode 100644 src/cluster.ts create mode 100644 src/utils/srv.ts create mode 100644 tests/cases/05_srv.ts diff --git a/src/client.ts b/src/client.ts index 0b135688..875478eb 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,97 +1,29 @@ -import { assert } from "../deps.ts"; import { Database } from "./database.ts"; -import { WireProtocol } from "./protocol/mod.ts"; -import { - ConnectOptions, - Credential, - Document, - ListDatabaseInfo, -} from "./types.ts"; +import { ConnectOptions, Document, ListDatabaseInfo } from "./types.ts"; import { parse } from "./utils/uri.ts"; -import { AuthContext, ScramAuthPlugin, X509AuthPlugin } from "./auth/mod.ts"; import { MongoError } from "./error.ts"; +import { Cluster } from "./cluster.ts"; +import { assert } from "../deps.ts"; const DENO_DRIVER_VERSION = "0.0.1"; -export interface DenoConnectOptions { - hostname: string; - port: number; - certFile?: string; -} - export class MongoClient { - #protocol?: WireProtocol; - #conn?: Deno.Conn; + #cluster?: Cluster; async connect( options: ConnectOptions | string, - serverIndex: number = 0, ): Promise { try { - if (typeof options === "string") { - options = parse(options); - } - let conn; - const denoConnectOps: DenoConnectOptions = { - hostname: options.servers[serverIndex].host, - port: options.servers[serverIndex].port, - }; - if (options.tls) { - if (options.certFile) { - denoConnectOps.certFile = options.certFile; - } - if (options.keyFile) { - if (options.keyFilePassword) { - throw new MongoError( - `Tls keyFilePassword not implemented in Deno driver`, - ); - //TODO, need something like const key = decrypt(options.keyFile) ... - } - throw new MongoError(`Tls keyFile not implemented in Deno driver`); - //TODO, need Deno.connectTls with something like key or keyFile option. - } - conn = await Deno.connectTls(denoConnectOps); - } else { - conn = await Deno.connect(denoConnectOps); - } - - this.#conn = conn; - this.#protocol = new WireProtocol(conn); - - if ((options as ConnectOptions).credential) { - const authContext = new AuthContext( - this.#protocol, - (options as ConnectOptions).credential, - options as ConnectOptions, - ); - const mechanism = (options as ConnectOptions).credential!.mechanism; - let authPlugin; - if (mechanism === "SCRAM-SHA-256") { - authPlugin = new ScramAuthPlugin("sha256"); //TODO AJUST sha256 - } else if (mechanism === "SCRAM-SHA-1") { - authPlugin = new ScramAuthPlugin("sha1"); - } else if (mechanism === "MONGODB-X509") { - authPlugin = new X509AuthPlugin(); - } else { - throw new MongoError( - `Auth mechanism not implemented in Deno driver: ${mechanism}`, - ); - } - const request = authPlugin.prepare(authContext); - authContext.response = await this.#protocol.commandSingle( - "admin", - request, - ); - await authPlugin.auth(authContext); - } else { - await this.#protocol.connect(); - } + const parsedOptions = typeof options === "string" + ? await parse(options) + : options; + const cluster = new Cluster(parsedOptions); + await cluster.connect(); + await cluster.authenticate(); + await cluster.updateMaster(); + this.#cluster = cluster; } catch (e) { - if (serverIndex < (options as ConnectOptions).servers.length - 1) { - return await this.connect(options, serverIndex + 1); - } else { - throw new MongoError(`Connection failed: ${e.message || e}`); - } + throw new MongoError(`Connection failed: ${e.message || e}`); } return this.database((options as ConnectOptions).db); } @@ -102,11 +34,11 @@ export class MongoClient { authorizedCollections?: boolean; comment?: Document; }): Promise { - assert(this.#protocol); if (!options) { options = {}; } - const { databases } = await this.#protocol.commandSingle("admin", { + assert(this.#cluster); + const { databases } = await this.#cluster.protocol.commandSingle("admin", { listDatabases: 1, ...options, }); @@ -115,20 +47,16 @@ export class MongoClient { // TODO: add test cases async runCommand(db: string, body: Document): Promise { - assert(this.#protocol); - return await this.#protocol.commandSingle(db, body); + assert(this.#cluster); + return await this.#cluster.protocol.commandSingle(db, body); } database(name: string): Database { - assert(this.#protocol); - return new Database(this.#protocol, name); + assert(this.#cluster); + return new Database(this.#cluster, name); } close() { - if (this.#conn) { - Deno.close(this.#conn.rid); - this.#conn = undefined; - this.#protocol = undefined; - } + if (this.#cluster) this.#cluster.close(); } } diff --git a/src/cluster.ts b/src/cluster.ts new file mode 100644 index 00000000..9f8ce6a0 --- /dev/null +++ b/src/cluster.ts @@ -0,0 +1,133 @@ +import { WireProtocol } from "./protocol/mod.ts"; +import { ConnectOptions } from "./types.ts"; +import { AuthContext, ScramAuthPlugin, X509AuthPlugin } from "./auth/mod.ts"; +import { MongoError } from "./error.ts"; +import { assert } from "../deps.ts"; +import { Server } from "./types.ts"; + +export interface DenoConnectOptions { + hostname: string; + port: number; + certFile?: string; +} + +export class Cluster { + #options: ConnectOptions; + #connections: Deno.Conn[]; + #protocols: WireProtocol[]; + #masterIndex: number; + + constructor(options: ConnectOptions) { + this.#options = options; + this.#connections = []; + this.#protocols = []; + this.#masterIndex = -1; + } + + async connect() { + const options = this.#options; + this.#connections = await Promise.all( + options.servers.map((server) => this.connectToServer(server, options)), + ); + } + + async connectToServer(server: Server, options: ConnectOptions) { + const denoConnectOps: DenoConnectOptions = { + hostname: server.host, + port: server.port, + }; + if (options.tls) { + if (options.certFile) { + denoConnectOps.certFile = options.certFile; + } + if (options.keyFile) { + if (options.keyFilePassword) { + throw new MongoError( + `Tls keyFilePassword not implemented in Deno driver`, + ); + //TODO, need something like const key = decrypt(options.keyFile) ... + } + throw new MongoError(`Tls keyFile not implemented in Deno driver`); + //TODO, need Deno.connectTls with something like key or keyFile option. + } + return await Deno.connectTls(denoConnectOps); + } else { + return await Deno.connect(denoConnectOps); + } + } + + async authenticate() { + const options = this.#options; + this.#protocols = await Promise.all( + this.#connections.map((conn) => this.authenticateToServer(conn, options)), + ); + } + + async authenticateToServer(conn: Deno.Conn, options: ConnectOptions) { + const protocol = new WireProtocol(conn); + if (options.credential) { + const authContext = new AuthContext( + protocol, + options.credential, + options, + ); + const mechanism = options.credential!.mechanism; + let authPlugin; + if (mechanism === "SCRAM-SHA-256") { + authPlugin = new ScramAuthPlugin("sha256"); //TODO AJUST sha256 + } else if (mechanism === "SCRAM-SHA-1") { + authPlugin = new ScramAuthPlugin("sha1"); + } else if (mechanism === "MONGODB-X509") { + authPlugin = new X509AuthPlugin(); + } else { + throw new MongoError( + `Auth mechanism not implemented in Deno driver: ${mechanism}`, + ); + } + const request = authPlugin.prepare(authContext); + authContext.response = await protocol.commandSingle( + "admin", // TODO: Should get the auth db from connectionOptions? + request, + ); + await authPlugin.auth(authContext); + } else { + await protocol.connect(); + } + return protocol; + } + + async updateMaster() { + const results = await Promise.all(this.#protocols.map((protocol) => { + return protocol.commandSingle( + "admin", + { hello: 1 }, + ); + })); + const masterIndex = results.findIndex((result) => result.isWritablePrimary); + if (masterIndex === -1) throw new Error(`Could not find a master node`); + this.#masterIndex = masterIndex; + } + + private getMaster() { + return { + protocol: this.#protocols[this.#masterIndex], + conn: this.#connections[this.#masterIndex], + }; + } + + get protocol() { + const protocol = this.getMaster().protocol; + assert(protocol); + return protocol; + } + + close() { + this.#connections.forEach((connection) => { + try { + Deno.close(connection.rid); + } catch (error) { + console.error(`Error closing connection: ${error}`); + } + }); + } +} diff --git a/src/database.ts b/src/database.ts index b72093a5..f46c81e7 100644 --- a/src/database.ts +++ b/src/database.ts @@ -1,6 +1,7 @@ import { Collection } from "./collection/mod.ts"; -import { CommandCursor, WireProtocol } from "./protocol/mod.ts"; +import { CommandCursor } from "./protocol/mod.ts"; import { CreateUserOptions, Document } from "./types.ts"; +import { Cluster } from "./cluster.ts"; interface ListCollectionsReponse { cursor: { @@ -22,14 +23,14 @@ export interface ListCollectionsResult { } export class Database { - #protocol: WireProtocol; + #cluster: Cluster; - constructor(protocol: WireProtocol, readonly name: string) { - this.#protocol = protocol; + constructor(cluster: Cluster, readonly name: string) { + this.#cluster = cluster; } collection(name: string): Collection { - return new Collection(this.#protocol, this.name, name); + return new Collection(this.#cluster.protocol, this.name, name); } listCollections(options?: { @@ -42,9 +43,9 @@ export class Database { options = {}; } return new CommandCursor( - this.#protocol, + this.#cluster.protocol, async () => { - const { cursor } = await this.#protocol.commandSingle< + const { cursor } = await this.#cluster.protocol.commandSingle< ListCollectionsReponse >(this.name, { listCollections: 1, @@ -82,7 +83,7 @@ export class Database { password: string, options?: CreateUserOptions, ) { - await this.#protocol.commandSingle(this.name, { + await this.#cluster.protocol.commandSingle(this.name, { createUser: options?.username ?? username, pwd: options?.password ?? password, customData: options?.customData, @@ -99,7 +100,7 @@ export class Database { writeConcern?: Document; comment?: Document; }) { - await this.#protocol.commandSingle(this.name, { + await this.#cluster.protocol.commandSingle(this.name, { dropUser: username, writeConcern: options?.writeConcern, comment: options?.comment, diff --git a/src/types.ts b/src/types.ts index dafa6a00..249aed8a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -18,7 +18,8 @@ export interface ConnectOptions { credential?: Credential; db: string; servers: Server[]; - [key: string]: any; + retryWrites?: boolean; + appname?: string; } export interface CountOptions { @@ -351,8 +352,8 @@ export interface Credential { export interface IndexOptions { /** - * Specifies the index’s fields. For each field, specify a key-value pair in which - * the key is the name of the field to index and the value is either the index direction + * Specifies the index’s fields. For each field, specify a key-value pair in which + * the key is the name of the field to index and the value is either the index direction * or index type. If specifying direction, specify 1 for ascending or -1 for descending. */ key: Document; @@ -368,34 +369,34 @@ export interface IndexOptions { background?: boolean; /** - * Optional. Creates a unique index so that the collection will not accept insertion + * Optional. Creates a unique index so that the collection will not accept insertion * or update of documents where the index key value matches an existing value in the index. * Specify true to create a unique index. The default value is false. */ unique?: boolean; /** - * Optional. If specified, the index only references documents that match the filter expression. + * Optional. If specified, the index only references documents that match the filter expression. * See Partial Indexes for more information. */ partialFilterExpression?: Document; /** - * Optional. If true, the index only references documents with the specified field. - * These indexes use less space but behave differently in some situations (particularly sorts). + * Optional. If true, the index only references documents with the specified field. + * These indexes use less space but behave differently in some situations (particularly sorts). * The default value is false. See Sparse Indexes for more information. */ sparse?: boolean; /** - * Optional. Specifies a value, in seconds, as a TTL to control how long MongoDB retains - * documents in this collection. See Expire Data from Collections by Setting TTL for + * Optional. Specifies a value, in seconds, as a TTL to control how long MongoDB retains + * documents in this collection. See Expire Data from Collections by Setting TTL for * more information on this functionality. This applies only to TTL indexes. */ expireAfterSeconds?: number; /** - * Optional. A flag that determines whether the index is hidden from the query planner. + * Optional. A flag that determines whether the index is hidden from the query planner. * A hidden index is not evaluated as part of query plan selection. Default is false. */ hidden?: boolean; @@ -406,26 +407,26 @@ export interface IndexOptions { storageEngine?: Document; /** - * Optional. For text indexes, a document that contains field and weight pairs. - * The weight is an integer ranging from 1 to 99,999 and denotes the significance - * of the field relative to the other indexed fields in terms of the score. - * You can specify weights for some or all the indexed fields. - * See Control Search Results with Weights to adjust the scores. + * Optional. For text indexes, a document that contains field and weight pairs. + * The weight is an integer ranging from 1 to 99,999 and denotes the significance + * of the field relative to the other indexed fields in terms of the score. + * You can specify weights for some or all the indexed fields. + * See Control Search Results with Weights to adjust the scores. * The default value is 1. */ weights?: Document; /** - * Optional. For text indexes, the language that determines the list of - * stop words and the rules for the stemmer and tokenizer. - * See Text Search Languages for the available languages and Specify a Language + * Optional. For text indexes, the language that determines the list of + * stop words and the rules for the stemmer and tokenizer. + * See Text Search Languages for the available languages and Specify a Language * for Text Index for more information and examples. The default value is english. */ default_language?: string; /** - * Optional. For text indexes, the name of the field, in the collection’s documents, - * that contains the override language for the document. The default value is language. + * Optional. For text indexes, the name of the field, in the collection’s documents, + * that contains the override language for the document. The default value is language. * See Use any Field to Specify the Language for a Document for an example. */ language_override?: string; @@ -457,8 +458,8 @@ export interface IndexOptions { max?: number; /** - * For geoHaystack indexes, specify the number of units within which to group the location values; - * i.e. group in the same bucket those location values that are within the specified number + * For geoHaystack indexes, specify the number of units within which to group the location values; + * i.e. group in the same bucket those location values that are within the specified number * of units to each other. The value must be greater than 0. */ bucketSize?: number; @@ -469,16 +470,16 @@ export interface IndexOptions { collation?: Document; /** - * Allows users to include or exclude specific field paths from a wildcard index using - * the { "$**" : 1} key pattern. This option is only valid if creating a wildcard index - * on all document fields. You cannot specify this option if creating a wildcard index + * Allows users to include or exclude specific field paths from a wildcard index using + * the { "$**" : 1} key pattern. This option is only valid if creating a wildcard index + * on all document fields. You cannot specify this option if creating a wildcard index * on a specific field path and its subfields, e.g. { "path.to.field.$**" : 1 } */ wildcardProjection?: Document; } export interface CreateIndexOptions { - /** + /** * Specifies the indexes to create. Each document in the array specifies a separate index. */ indexes: IndexOptions[]; @@ -487,8 +488,8 @@ export interface CreateIndexOptions { writeConcern?: Document; /** - * Optional. The minimum number of data-bearing voting replica set members (i.e. commit quorum), - * including the primary, that must report a successful index build before the primary marks the indexes as ready. + * Optional. The minimum number of data-bearing voting replica set members (i.e. commit quorum), + * including the primary, that must report a successful index build before the primary marks the indexes as ready. * A “voting” member is any replica set member where members[n].votes is greater than 0. */ commitQuorum?: number | string; diff --git a/src/utils/srv.ts b/src/utils/srv.ts new file mode 100644 index 00000000..aab28007 --- /dev/null +++ b/src/utils/srv.ts @@ -0,0 +1,117 @@ +import { parseSrvUrl } from "./uri.ts"; +import { ConnectOptions } from "../types.ts"; + +enum AllowedOption { + authSource = "authSource", + replicaSet = "replicaSet", + loadBalanced = "loadBalanced", +} + +function isAllowedOption(key: unknown): key is AllowedOption { + return Object.values(AllowedOption).includes(key as AllowedOption); +} + +interface Resolver { + resolveDns: typeof Deno.resolveDns; +} + +interface SRVResolveResultOptions { + authSource?: string; + replicaSet?: string; + loadBalanced?: string; +} + +interface SRVResolveResult { + servers: { host: string; port: number }[]; + options: SRVResolveResultOptions; +} + +class SRVError extends Error { + constructor(message?: string) { + super(message); + this.name = "SRVError"; + } +} + +export class Srv { + resolver: Resolver; + + constructor(resolver = { resolveDns: Deno.resolveDns }) { + this.resolver = resolver; + } + + async resolveSrvUrl(urlString: string): Promise { + const options = parseSrvUrl(urlString); + const { srvServer, ...connectOptions } = options; + if (!srvServer) { + throw new SRVError( + `Could not parse srv server address from ${urlString}`, + ); + } + const resolveResult = await this.resolve(srvServer); + return { + servers: resolveResult.servers, + // TODO: Check and throw on invalid options + ...resolveResult.options, + ...connectOptions, + }; + } + + async resolve(url: string): Promise { + const tokens = url.split("."); + if (tokens.length < 3) { + throw new SRVError( + `Expected url in format 'host.domain.tld', received ${url}`, + ); + } + + const srvRecord = await this.resolver.resolveDns( + `_mongodb._tcp.${url}`, + "SRV", + ); + if (!(srvRecord?.length > 0)) { + throw new SRVError( + `Expected at least one SRV record, received ${srvRecord + ?.length} for url ${url}`, + ); + } + const txtRecords = await this.resolver.resolveDns(url, "TXT"); + if (txtRecords?.length !== 1) { + throw new SRVError( + `Expected exactly one TXT record, received ${txtRecords + ?.length} for url ${url}`, + ); + } + + const servers = srvRecord.map((record) => { + return { + host: record.target, + port: record.port, + }; + }); + + const optionsUri = txtRecords[0].join(""); + const options: { valid: SRVResolveResultOptions; illegal: string[] } = { + valid: {}, + illegal: [], + }; + optionsUri.split("&").forEach((option: string) => { + const [key, value] = option.split("="); + if (isAllowedOption(key) && !!value) options.valid[key] = value; + else options.illegal.push(option); + }); + + if (options.illegal.length !== 0) { + throw new SRVError( + `Illegal uri options: ${options.illegal}. Allowed options: ${ + Object.values(AllowedOption) + }`, + ); + } + + return { + servers, + options: options.valid, + }; + } +} diff --git a/src/utils/uri.ts b/src/utils/uri.ts index b1469b29..e78873c9 100644 --- a/src/utils/uri.ts +++ b/src/utils/uri.ts @@ -1,5 +1,6 @@ // mongodb://username:password@example.com:27017,example2.com:27017,...,example.comN:27017/database?key=value&keyN=valueN import { ConnectOptions, Credential, Server } from "../types.ts"; +import { Srv } from "./srv.ts"; interface Parts { auth?: { user: string; password?: string }; @@ -105,7 +106,68 @@ export function parse_url(url: string): Parts { return parse(url); } -export function parse(url: string, optOverride: any = {}): ConnectOptions { +export function isSrvUrl(url: string) { + return /^mongodb\+srv/.test(url); +} + +export type SrvConnectOptions = Omit & { + srvServer?: string; +}; + +export function parseSrvUrl(url: string): SrvConnectOptions { + const data = parse_url(url); + const connectOptions: SrvConnectOptions = { + db: (data.pathname && data.pathname.length > 1) + ? data.pathname.substring(1) + : "admin", + }; + + if (data.auth) { + connectOptions.credential = { + username: data.auth.user, + password: data.auth.password, + db: connectOptions.db, + mechanism: data.search.authMechanism || "SCRAM-SHA-256", + }; + } + connectOptions.compression = data.search.compressors + ? data.search.compressors.split(",") + : []; + connectOptions.srvServer = data.servers?.[0].host; + + if (data.search.appname) { + connectOptions.appname = data.search.appname; + } + if (data.search.tls) { + connectOptions.tls = data.search.tls === "true"; + } else { + connectOptions.tls = true; + } + if (data.search.tlsCAFile) { + connectOptions.certFile = data.search.tlsCAFile; + } + if (data.search.tlsCertificateKeyFile) { + connectOptions.keyFile = data.search.tlsCertificateKeyFile; + } + if (data.search.tlsCertificateKeyFilePassword) { + connectOptions.keyFilePassword = data.search.tlsCertificateKeyFilePassword; + } + if (data.search.safe) { + connectOptions.safe = data.search.safe === "true"; + } + if (data.search.retryWrites) { + connectOptions.retryWrites = data.search.retryWrites === "true"; + } + return connectOptions; +} + +export function parse(url: string): Promise { + return isSrvUrl(url) + ? new Srv().resolveSrvUrl(url) + : Promise.resolve(parseNormalUrl(url)); +} + +function parseNormalUrl(url: string): ConnectOptions { const data = parse_url(url); const connectOptions: ConnectOptions = { servers: data.servers!, db: "" }; for (var i = 0; i < connectOptions.servers.length; i++) { @@ -146,5 +208,5 @@ export function parse(url: string, optOverride: any = {}): ConnectOptions { if (data.search.safe) { connectOptions.safe = data.search.safe === "true"; } - return { ...connectOptions, ...optOverride } as ConnectOptions; + return connectOptions; } diff --git a/tests/cases/00_uri.ts b/tests/cases/00_uri.ts index 7e47bac6..fc360d3c 100644 --- a/tests/cases/00_uri.ts +++ b/tests/cases/00_uri.ts @@ -1,11 +1,11 @@ -import { parse } from "../../src/utils/uri.ts"; +import { parse, parseSrvUrl } from "../../src/utils/uri.ts"; import { assertEquals } from "./../test.deps.ts"; export default function uriTests() { Deno.test({ name: "should correctly parse mongodb://localhost", - fn() { - const options = parse("mongodb://localhost/"); + async fn() { + const options = await parse("mongodb://localhost/"); assertEquals(options.db, "admin"); assertEquals(options.servers.length, 1); assertEquals(options.servers[0].host, "localhost"); @@ -15,8 +15,8 @@ export default function uriTests() { Deno.test({ name: "should correctly parse mongodb://localhost:27017", - fn() { - const options = parse("mongodb://localhost:27017/"); + async fn() { + const options = await parse("mongodb://localhost:27017/"); assertEquals(options.db, "admin"); assertEquals(options.servers.length, 1); assertEquals(options.servers[0].host, "localhost"); @@ -27,8 +27,8 @@ export default function uriTests() { Deno.test({ name: "should correctly parse mongodb://localhost:27017/test?appname=hello%20world", - fn() { - const options = parse( + async fn() { + const options = await parse( "mongodb://localhost:27017/test?appname=hello%20world", ); assertEquals(options.appname, "hello world"); @@ -38,8 +38,8 @@ export default function uriTests() { Deno.test({ name: "should correctly parse mongodb://localhost/?safe=true&readPreference=secondary", - fn() { - const options = parse( + async fn() { + const options = await parse( "mongodb://localhost/?safe=true&readPreference=secondary", ); assertEquals(options.db, "admin"); @@ -51,8 +51,8 @@ export default function uriTests() { Deno.test({ name: "should correctly parse mongodb://localhost:28101/", - fn() { - const options = parse("mongodb://localhost:28101/"); + async fn() { + const options = await parse("mongodb://localhost:28101/"); assertEquals(options.db, "admin"); assertEquals(options.servers.length, 1); assertEquals(options.servers[0].host, "localhost"); @@ -61,8 +61,8 @@ export default function uriTests() { }); Deno.test({ name: "should correctly parse mongodb://fred:foobar@localhost/baz", - fn() { - const options = parse("mongodb://fred:foobar@localhost/baz"); + async fn() { + const options = await parse("mongodb://fred:foobar@localhost/baz"); assertEquals(options.db, "baz"); assertEquals(options.servers.length, 1); assertEquals(options.servers[0].host, "localhost"); @@ -73,8 +73,8 @@ export default function uriTests() { Deno.test({ name: "should correctly parse mongodb://fred:foo%20bar@localhost/baz", - fn() { - const options = parse("mongodb://fred:foo%20bar@localhost/baz"); + async fn() { + const options = await parse("mongodb://fred:foo%20bar@localhost/baz"); assertEquals(options.db, "baz"); assertEquals(options.servers.length, 1); assertEquals(options.servers[0].host, "localhost"); @@ -85,8 +85,8 @@ export default function uriTests() { Deno.test({ name: "should correctly parse mongodb://%2Ftmp%2Fmongodb-27017.sock", - fn() { - const options = parse("mongodb://%2Ftmp%2Fmongodb-27017.sock"); + async fn() { + const options = await parse("mongodb://%2Ftmp%2Fmongodb-27017.sock"); assertEquals(options.servers.length, 1); assertEquals(options.servers[0].domainSocket, "/tmp/mongodb-27017.sock"); assertEquals(options.db, "admin"); @@ -96,8 +96,10 @@ export default function uriTests() { Deno.test({ name: "should correctly parse mongodb://fred:foo@%2Ftmp%2Fmongodb-27017.sock", - fn() { - const options = parse("mongodb://fred:foo@%2Ftmp%2Fmongodb-27017.sock"); + async fn() { + const options = await parse( + "mongodb://fred:foo@%2Ftmp%2Fmongodb-27017.sock", + ); assertEquals(options.servers.length, 1); assertEquals(options.servers[0].domainSocket, "/tmp/mongodb-27017.sock"); assertEquals(options.credential!.username, "fred"); @@ -109,8 +111,8 @@ export default function uriTests() { Deno.test({ name: "should correctly parse mongodb://fred:foo@%2Ftmp%2Fmongodb-27017.sock/somedb", - fn() { - const options = parse( + async fn() { + const options = await parse( "mongodb://fred:foo@%2Ftmp%2Fmongodb-27017.sock/somedb", ); assertEquals(options.servers.length, 1); @@ -124,8 +126,8 @@ export default function uriTests() { Deno.test({ name: "should correctly parse mongodb://fred:foo@%2Ftmp%2Fmongodb-27017.sock/somedb?safe=true", - fn() { - const options = parse( + async fn() { + const options = await parse( "mongodb://fred:foo@%2Ftmp%2Fmongodb-27017.sock/somedb?safe=true", ); assertEquals(options.servers.length, 1); @@ -139,8 +141,8 @@ export default function uriTests() { Deno.test({ name: "should correctly parse mongodb://fred:foobar@localhost,server2.test:28101/baz", - fn() { - const options = parse( + async fn() { + const options = await parse( "mongodb://fred:foobar@localhost,server2.test:28101/baz", ); assertEquals(options.db, "baz"); @@ -154,4 +156,20 @@ export default function uriTests() { }, }); // TODO: add more tests (https://github.com/mongodb/node-mongodb-native/blob/3.6/test/functional/url_parser.test.js) + + Deno.test({ + name: + "should correctly parse mongodb+srv://someUser:somePassword@somesubdomain.somedomain.com/someDatabaseName?retryWrites=true&w=majority", + async fn() { + const options = await parseSrvUrl( + "mongodb+srv://someUser:somePassword@somesubdomain.somedomain.com/someDatabaseName?retryWrites=true&w=majority", + ); + assertEquals(options.db, "someDatabaseName"); + assertEquals(options.credential?.username, "someUser"); + assertEquals(options.credential?.password, "somePassword"); + assertEquals(options.retryWrites, true); + // @ts-ignore + assertEquals(options["servers"], undefined); + }, + }); } diff --git a/tests/cases/05_srv.ts b/tests/cases/05_srv.ts new file mode 100644 index 00000000..b493833a --- /dev/null +++ b/tests/cases/05_srv.ts @@ -0,0 +1,150 @@ +import { assertEquals, assertThrowsAsync } from "../test.deps.ts"; +import { Srv } from "../../src/utils/srv.ts"; + +function mockResolver( + srvRecords: Partial[] = [], + txtRecords: string[][] = [], +) { + return { + resolveDns: (_url: string, type: Deno.RecordType) => { + if (type === "SRV") return srvRecords; + if (type === "TXT") return txtRecords; + }, + } as any; +} + +export default function srvTests() { + Deno.test({ + name: "SRV: it throws an error if url doesn't have subdomain", + fn() { + assertThrowsAsync( + () => new Srv().resolve("foo.bar"), + Error, + "Expected url in format 'host.domain.tld', received foo.bar", + ); + }, + }); + + Deno.test({ + name: + "SRV: it throws an error if SRV resolution doesn't return any SRV records", + fn() { + assertThrowsAsync( + () => new Srv(mockResolver()).resolve("mongohost.mongodomain.com"), + Error, + "Expected at least one SRV record, received 0 for url mongohost.mongodomain.com", + ); + }, + }); + + Deno.test({ + name: "SRV: it throws an error if TXT resolution returns no records", + fn() { + assertThrowsAsync( + () => + new Srv(mockResolver([{ target: "mongohost1.mongodomain.com" }])) + .resolve("mongohost.mongodomain.com"), + Error, + "Expected exactly one TXT record, received 0 for url mongohost.mongodomain.com", + ); + }, + }); + + Deno.test({ + name: + "SRV: it throws an error if TXT resolution returns more than one record", + fn() { + assertThrowsAsync( + () => + new Srv( + mockResolver( + [{ target: "mongohost1.mongodomain.com" }], + [["replicaSet=rs-0"], ["authSource=admin"]], + ), + ) + .resolve("mongohost.mongodomain.com"), + Error, + "Expected exactly one TXT record, received 2 for url mongohost.mongodomain.com", + ); + }, + }); + + Deno.test({ + name: "SRV: it throws an error if TXT record contains illegal options", + fn() { + assertThrowsAsync( + () => + new Srv( + mockResolver( + [{ target: "mongohost1.mongodomain.com" }], + [["replicaSet=rs-0&authSource=admin&ssl=true"]], + ), + ) + .resolve("mongohost.mongodomain.com"), + Error, + "Illegal uri options: ssl=true", + ); + }, + }); + + Deno.test({ + name: "SRV: it correctly parses seedlist and options for valid records", + async fn() { + const result = await new Srv( + mockResolver([ + { + target: "mongohost1.mongodomain.com", + port: 27015, + }, + { + target: "mongohost2.mongodomain.com", + port: 27017, + }, + ], [["replicaSet=rs-0&authSource=admin"]]), + ).resolve("mongohost.mongodomain.com"); + assertEquals(result.servers.length, 2); + const server1 = result.servers.find( + (server) => server.host === "mongohost1.mongodomain.com", + ); + const server2 = result.servers.find((server) => + server.host === "mongohost2.mongodomain.com" + ); + assertEquals(server1!.port, 27015); + assertEquals(server2!.port, 27017); + assertEquals(result.options.replicaSet, "rs-0"); + assertEquals(result.options.authSource, "admin"); + assertEquals(result.options.loadBalanced, undefined); + }, + }); + + Deno.test({ + name: + "SRV: it correctly parses seedlist and options for options split in two strings", + async fn() { + const result = await new Srv( + mockResolver([ + { + target: "mongohost1.mongodomain.com", + port: 27015, + }, + { + target: "mongohost2.mongodomain.com", + port: 27017, + }, + ], [["replicaS", "et=rs-0&authSource=admin"]]), + ).resolve("mongohost.mongodomain.com"); + assertEquals(result.servers.length, 2); + const server1 = result.servers.find( + (server) => server.host === "mongohost1.mongodomain.com", + ); + const server2 = result.servers.find((server) => + server.host === "mongohost2.mongodomain.com" + ); + assertEquals(server1!.port, 27015); + assertEquals(server2!.port, 27017); + assertEquals(result.options.replicaSet, "rs-0"); + assertEquals(result.options.authSource, "admin"); + assertEquals(result.options.loadBalanced, undefined); + }, + }); +} diff --git a/tests/test.ts b/tests/test.ts index 61bdb7f2..4dc4482c 100644 --- a/tests/test.ts +++ b/tests/test.ts @@ -3,6 +3,8 @@ import authTests from "./cases/01_auth.ts"; import connectTests from "./cases/02_connect.ts"; import curdTests from "./cases/03_curd.ts"; import indexesTests from "./cases/04_indexes.ts"; +import srvTests from "./cases/05_srv.ts"; + import cleanup from "./cases/99_cleanup.ts"; uriTests(); @@ -10,5 +12,6 @@ authTests(); connectTests(); curdTests(); indexesTests(); +srvTests(); cleanup();