Skip to content

Commit d154682

Browse files
whygee-devrkistner
andauthored
Update normalizeMongoConfig function and its tests to allow replica URI (#250)
* Update normalizeMongoConfig function and its tests to allow replica URI * Keep normalizeMongoConfig return spacing as is * Improve normalizeMongoConfig tests * Remove uri-js from lib-mongodb * Update pnpm-lock.yml * Add makeMultiHostnameLookupFunction and use it for normalizeMongoConfig * Fix lookup function. * Redo pnpm-lock.yaml. * Add changeset. --------- Co-authored-by: Ralf Kistner <[email protected]>
1 parent 94f657d commit d154682

File tree

7 files changed

+253
-23
lines changed

7 files changed

+253
-23
lines changed

.changeset/flat-drinks-refuse.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@powersync/service-module-mongodb': minor
3+
'@powersync/lib-service-mongodb': minor
4+
'@powersync/service-core': minor
5+
'@powersync/service-image': minor
6+
'@powersync/lib-services-framework': patch
7+
---
8+
9+
[MongoDB] Add support for plain "mongodb://" URIs for replica sets (multiple hostnames).

libs/lib-mongodb/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@
3131
"@powersync/lib-services-framework": "workspace:*",
3232
"bson": "^6.10.3",
3333
"mongodb": "^6.14.1",
34-
"ts-codec": "^1.3.0",
35-
"uri-js": "^4.4.1"
34+
"mongodb-connection-string-url": "^3.0.2",
35+
"ts-codec": "^1.3.0"
3636
},
3737
"devDependencies": {}
3838
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
declare module 'mongodb-connection-string-url' {
2+
export class ConnectionURI {
3+
constructor(uri: string);
4+
toString(): string;
5+
searchParams: URLSearchParams;
6+
pathname: string;
7+
username: string;
8+
password: string;
9+
hosts: string[];
10+
isSRV: boolean;
11+
href: string;
12+
}
13+
export default ConnectionURI;
14+
}

libs/lib-mongodb/src/types/types.ts

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
1-
import { ErrorCode, LookupOptions, makeHostnameLookupFunction, ServiceError } from '@powersync/lib-services-framework';
1+
import {
2+
ErrorCode,
3+
LookupOptions,
4+
makeMultiHostnameLookupFunction,
5+
ServiceError
6+
} from '@powersync/lib-services-framework';
27
import * as t from 'ts-codec';
3-
import * as urijs from 'uri-js';
8+
import ConnectionURI from 'mongodb-connection-string-url';
9+
import { LookupFunction } from 'node:net';
410

511
export const MONGO_CONNECTION_TYPE = 'mongodb' as const;
612

@@ -17,6 +23,14 @@ export const BaseMongoConfig = t.object({
1723
export type BaseMongoConfig = t.Encoded<typeof BaseMongoConfig>;
1824
export type BaseMongoConfigDecoded = t.Decoded<typeof BaseMongoConfig>;
1925

26+
export type NormalizedMongoConfig = {
27+
uri: string;
28+
database: string;
29+
username: string;
30+
password: string;
31+
lookup: LookupFunction | undefined;
32+
};
33+
2034
/**
2135
* Construct a mongodb URI, without username, password or ssl options.
2236
*
@@ -33,29 +47,36 @@ export function baseUri(options: BaseMongoConfig) {
3347
*
3448
* For use by both storage and mongo module.
3549
*/
36-
export function normalizeMongoConfig(options: BaseMongoConfigDecoded) {
37-
let uri = urijs.parse(options.uri);
50+
export function normalizeMongoConfig(options: BaseMongoConfigDecoded): NormalizedMongoConfig {
51+
let uri: ConnectionURI;
3852

39-
const database = options.database ?? uri.path?.substring(1) ?? '';
53+
try {
54+
uri = new ConnectionURI(options.uri);
55+
} catch (error) {
56+
throw new ServiceError(
57+
ErrorCode.PSYNC_S1109,
58+
`MongoDB connection: invalid URI ${error instanceof Error ? `- ${error.message}` : ''}`
59+
);
60+
}
4061

41-
const userInfo = uri.userinfo?.split(':');
62+
const database = options.database ?? uri.pathname.split('/')[1] ?? '';
63+
const username = options.username ?? uri.username;
64+
const password = options.password ?? uri.password;
4265

43-
const username = options.username ?? userInfo?.[0];
44-
const password = options.password ?? userInfo?.[1];
66+
uri.password = '';
67+
uri.username = '';
4568

4669
if (database == '') {
4770
throw new ServiceError(ErrorCode.PSYNC_S1105, `MongoDB connection: database required`);
4871
}
4972

50-
delete uri.userinfo;
51-
5273
const lookupOptions: LookupOptions = {
5374
reject_ip_ranges: options.reject_ip_ranges ?? []
5475
};
55-
const lookup = makeHostnameLookupFunction(uri.host ?? '', lookupOptions);
76+
const lookup = makeMultiHostnameLookupFunction(uri.hosts, lookupOptions);
5677

5778
return {
58-
uri: urijs.serialize(uri),
79+
uri: uri.toString(),
5980
database,
6081

6182
username,
Lines changed: 169 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,179 @@
11
import { describe, expect, test } from 'vitest';
22
import { normalizeMongoConfig } from '../../src/types/types.js';
3+
import { LookupAddress } from 'node:dns';
4+
import { ErrorCode, ServiceError } from '@powersync/lib-services-framework';
35

46
describe('config', () => {
5-
test('Should resolve database', () => {
7+
test('Should normalize a simple URI', () => {
8+
const uri = 'mongodb://localhost:27017/powersync_test';
69
const normalized = normalizeMongoConfig({
710
type: 'mongodb',
8-
uri: 'mongodb://localhost:27017/powersync_test'
11+
uri
912
});
13+
expect(normalized.uri).equals(uri);
1014
expect(normalized.database).equals('powersync_test');
1115
});
16+
17+
test('Should normalize an URI with auth', () => {
18+
const uri = 'mongodb://user:pass@localhost:27017/powersync_test';
19+
const normalized = normalizeMongoConfig({
20+
type: 'mongodb',
21+
uri
22+
});
23+
expect(normalized.uri).equals(uri.replace('user:pass@', ''));
24+
expect(normalized.database).equals('powersync_test');
25+
});
26+
27+
test('Should normalize an URI with query params', () => {
28+
const uri = 'mongodb://localhost:27017/powersync_test?query=test';
29+
const normalized = normalizeMongoConfig({
30+
type: 'mongodb',
31+
uri
32+
});
33+
expect(normalized.uri).equals(uri);
34+
expect(normalized.database).equals('powersync_test');
35+
});
36+
37+
test('Should normalize a replica set URI', () => {
38+
const uri =
39+
'mongodb://mongodb-0.mongodb.powersync.svc.cluster.local:27017,mongodb-1.mongodb.powersync.svc.cluster.local:27017,mongodb-2.mongodb.powersync.svc.cluster.local:27017/powersync_test?replicaSet=rs0';
40+
const normalized = normalizeMongoConfig({
41+
type: 'mongodb',
42+
uri
43+
});
44+
expect(normalized.uri).equals(uri);
45+
expect(normalized.database).equals('powersync_test');
46+
});
47+
48+
test('Should normalize a replica set URI with auth', () => {
49+
const uri =
50+
'mongodb://user:[email protected]:27017,mongodb-1.mongodb.powersync.svc.cluster.local:27017,mongodb-2.mongodb.powersync.svc.cluster.local:27017/powersync_test?replicaSet=rs0';
51+
const normalized = normalizeMongoConfig({
52+
type: 'mongodb',
53+
uri
54+
});
55+
expect(normalized.uri).equals(uri.replace('user:pass@', ''));
56+
expect(normalized.database).equals('powersync_test');
57+
expect(normalized.username).equals('user');
58+
expect(normalized.password).equals('pass');
59+
});
60+
61+
test('Should normalize a +srv URI', () => {
62+
const uri = 'mongodb+srv://user:pass@localhost/powersync_test';
63+
const normalized = normalizeMongoConfig({
64+
type: 'mongodb',
65+
uri
66+
});
67+
expect(normalized.uri).equals(uri.replace('user:pass@', ''));
68+
expect(normalized.database).equals('powersync_test');
69+
expect(normalized.username).equals('user');
70+
expect(normalized.password).equals('pass');
71+
});
72+
73+
test('Should prioritize username and password that are specified explicitly', () => {
74+
const uri = 'mongodb://user:pass@localhost:27017/powersync_test';
75+
const normalized = normalizeMongoConfig({
76+
type: 'mongodb',
77+
uri,
78+
username: 'user2',
79+
password: 'pass2'
80+
});
81+
expect(normalized.uri).equals(uri.replace('user:pass@', ''));
82+
expect(normalized.database).equals('powersync_test');
83+
expect(normalized.username).equals('user2');
84+
expect(normalized.password).equals('pass2');
85+
});
86+
87+
test('Should make a lookup function for a single IP host', async () => {
88+
let err: ServiceError | undefined;
89+
try {
90+
normalizeMongoConfig({
91+
type: 'mongodb',
92+
uri: 'mongodb://127.0.0.1/powersync_test',
93+
reject_ip_ranges: ['127.0.0.1/0']
94+
});
95+
} catch (e) {
96+
err = e as ServiceError;
97+
}
98+
99+
expect(err?.toJSON().code).toEqual(ErrorCode.PSYNC_S2203);
100+
});
101+
102+
test('Should make a lookup function for a single hostname', async () => {
103+
const lookup = normalizeMongoConfig({
104+
type: 'mongodb',
105+
uri: 'mongodb://host/powersync_test',
106+
reject_ip_ranges: ['host']
107+
}).lookup;
108+
109+
const result = await new Promise((resolve, reject) => {
110+
lookup!('host', {}, (e, address) => {
111+
resolve(e);
112+
});
113+
});
114+
115+
expect(result instanceof Error).toBe(true);
116+
});
117+
118+
test('Should make a lookup function for multiple IP hosts', async () => {
119+
let err: ServiceError | undefined;
120+
try {
121+
normalizeMongoConfig({
122+
type: 'mongodb',
123+
uri: 'mongodb://127.0.0.1,127.0.0.2/powersync_test',
124+
reject_ip_ranges: ['127.0.0.1/0']
125+
});
126+
} catch (e) {
127+
err = e as ServiceError;
128+
}
129+
130+
expect(err?.toJSON().code).toEqual(ErrorCode.PSYNC_S2203);
131+
});
132+
133+
test('Should make a lookup function for multiple hosts', async () => {
134+
const lookup = normalizeMongoConfig({
135+
type: 'mongodb',
136+
uri: 'mongodb://host1,host2/powersync_test',
137+
reject_ip_ranges: ['host1']
138+
}).lookup;
139+
140+
const result = await new Promise((resolve, reject) => {
141+
lookup!('host1', {}, (e, address) => {
142+
resolve(e);
143+
});
144+
});
145+
146+
expect(result instanceof Error).toBe(true);
147+
});
148+
149+
describe('errors', () => {
150+
test('Should throw error when no database specified', () => {
151+
['mongodb://localhost:27017', 'mongodb://localhost:27017/'].forEach((uri) => {
152+
expect(() =>
153+
normalizeMongoConfig({
154+
type: 'mongodb',
155+
uri
156+
})
157+
).toThrow('[PSYNC_S1105] MongoDB connection: database required');
158+
});
159+
});
160+
161+
test('Should throw error when URI has invalid scheme', () => {
162+
expect(() =>
163+
normalizeMongoConfig({
164+
type: 'mongodb',
165+
uri: 'not-a-uri'
166+
})
167+
).toThrow('[PSYNC_S1109] MongoDB connection: invalid URI');
168+
});
169+
170+
test('Should throw error when URI has invalid host', () => {
171+
expect(() =>
172+
normalizeMongoConfig({
173+
type: 'mongodb',
174+
uri: 'mongodb://'
175+
})
176+
).toThrow('[PSYNC_S1109] MongoDB connection: invalid URI');
177+
});
178+
});
12179
});

libs/lib-services/src/ip/lookup.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,25 @@ export interface LookupOptions {
99
reject_ipv6?: boolean;
1010
}
1111

12+
/**
13+
* Generate a custom DNS lookup function for multiple hosts, that rejects specific IP ranges.
14+
*
15+
* If one of the hostnames is an IP, this synchronously validates it.
16+
*
17+
* @returns a function to use as the `lookup` option in `net.connect`.
18+
*/
19+
export function makeMultiHostnameLookupFunction(
20+
hostnames: string[],
21+
lookupOptions: LookupOptions
22+
): net.LookupFunction | undefined {
23+
// If any of the hostnames are IPs, validate them synchronously
24+
for (let host of hostnames) {
25+
validateIpHostname(host, lookupOptions);
26+
}
27+
28+
return makeLookupFunction(lookupOptions);
29+
}
30+
1231
/**
1332
* Generate a custom DNS lookup function, that rejects specific IP ranges.
1433
*

pnpm-lock.yaml

Lines changed: 7 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)