Skip to content

Commit 460a65c

Browse files
authored
feat: Allow option publicServerURL to be set dynamically as asynchronous function (#9803)
1 parent f27b050 commit 460a65c

File tree

9 files changed

+247
-17
lines changed

9 files changed

+247
-17
lines changed

spec/index.spec.js

Lines changed: 168 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -363,7 +363,7 @@ describe('server', () => {
363363

364364
it('should throw when getting invalid mount', done => {
365365
reconfigureServer({ publicServerURL: 'blabla:/some' }).catch(error => {
366-
expect(error).toEqual('publicServerURL should be a valid HTTPS URL starting with https://');
366+
expect(error).toEqual('The option publicServerURL must be a valid URL starting with http:// or https://.');
367367
done();
368368
});
369369
});
@@ -685,4 +685,171 @@ describe('server', () => {
685685
})
686686
.catch(done.fail);
687687
});
688+
689+
describe('publicServerURL', () => {
690+
it('should load publicServerURL', async () => {
691+
await reconfigureServer({
692+
publicServerURL: () => 'https://example.com/1',
693+
});
694+
695+
await new Parse.Object('TestObject').save();
696+
697+
const config = Config.get(Parse.applicationId);
698+
expect(config.publicServerURL).toEqual('https://example.com/1');
699+
});
700+
701+
it('should load publicServerURL from Promise', async () => {
702+
await reconfigureServer({
703+
publicServerURL: () => Promise.resolve('https://example.com/1'),
704+
});
705+
706+
await new Parse.Object('TestObject').save();
707+
708+
const config = Config.get(Parse.applicationId);
709+
expect(config.publicServerURL).toEqual('https://example.com/1');
710+
});
711+
712+
it('should handle publicServerURL function throwing error', async () => {
713+
const errorMessage = 'Failed to get public server URL';
714+
await reconfigureServer({
715+
publicServerURL: () => {
716+
throw new Error(errorMessage);
717+
},
718+
});
719+
720+
// The error should occur when trying to save an object (which triggers loadKeys in middleware)
721+
await expectAsync(
722+
new Parse.Object('TestObject').save()
723+
).toBeRejected();
724+
});
725+
726+
it('should handle publicServerURL Promise rejection', async () => {
727+
const errorMessage = 'Async fetch of public server URL failed';
728+
await reconfigureServer({
729+
publicServerURL: () => Promise.reject(new Error(errorMessage)),
730+
});
731+
732+
// The error should occur when trying to save an object (which triggers loadKeys in middleware)
733+
await expectAsync(
734+
new Parse.Object('TestObject').save()
735+
).toBeRejected();
736+
});
737+
738+
it('executes publicServerURL function on every config access', async () => {
739+
let counter = 0;
740+
await reconfigureServer({
741+
publicServerURL: () => {
742+
counter++;
743+
return `https://example.com/${counter}`;
744+
},
745+
});
746+
747+
// First request - should call the function
748+
await new Parse.Object('TestObject').save();
749+
const config1 = Config.get(Parse.applicationId);
750+
expect(config1.publicServerURL).toEqual('https://example.com/1');
751+
expect(counter).toEqual(1);
752+
753+
// Second request - should call the function again
754+
await new Parse.Object('TestObject').save();
755+
const config2 = Config.get(Parse.applicationId);
756+
expect(config2.publicServerURL).toEqual('https://example.com/2');
757+
expect(counter).toEqual(2);
758+
759+
// Third request - should call the function again
760+
await new Parse.Object('TestObject').save();
761+
const config3 = Config.get(Parse.applicationId);
762+
expect(config3.publicServerURL).toEqual('https://example.com/3');
763+
expect(counter).toEqual(3);
764+
});
765+
766+
it('executes publicServerURL function on every password reset email', async () => {
767+
let counter = 0;
768+
const emailCalls = [];
769+
770+
const emailAdapter = MockEmailAdapterWithOptions({
771+
sendPasswordResetEmail: ({ link }) => {
772+
emailCalls.push(link);
773+
return Promise.resolve();
774+
},
775+
});
776+
777+
await reconfigureServer({
778+
appName: 'test-app',
779+
publicServerURL: () => {
780+
counter++;
781+
return `https://example.com/${counter}`;
782+
},
783+
emailAdapter,
784+
});
785+
786+
// Create a user
787+
const user = new Parse.User();
788+
user.setUsername('user');
789+
user.setPassword('pass');
790+
user.setEmail('user@example.com');
791+
await user.signUp();
792+
793+
// Should use first publicServerURL
794+
const counterBefore1 = counter;
795+
await Parse.User.requestPasswordReset('user@example.com');
796+
await jasmine.timeout();
797+
expect(emailCalls.length).toEqual(1);
798+
expect(emailCalls[0]).toContain(`https://example.com/${counterBefore1 + 1}`);
799+
expect(counter).toBeGreaterThanOrEqual(2);
800+
801+
// Should use updated publicServerURL
802+
const counterBefore2 = counter;
803+
await Parse.User.requestPasswordReset('user@example.com');
804+
await jasmine.timeout();
805+
expect(emailCalls.length).toEqual(2);
806+
expect(emailCalls[1]).toContain(`https://example.com/${counterBefore2 + 1}`);
807+
expect(counterBefore2).toBeGreaterThan(counterBefore1);
808+
});
809+
810+
it('executes publicServerURL function on every verification email', async () => {
811+
let counter = 0;
812+
const emailCalls = [];
813+
814+
const emailAdapter = MockEmailAdapterWithOptions({
815+
sendVerificationEmail: ({ link }) => {
816+
emailCalls.push(link);
817+
return Promise.resolve();
818+
},
819+
});
820+
821+
await reconfigureServer({
822+
appName: 'test-app',
823+
verifyUserEmails: true,
824+
publicServerURL: () => {
825+
counter++;
826+
return `https://example.com/${counter}`;
827+
},
828+
emailAdapter,
829+
});
830+
831+
// Should trigger verification email with first publicServerURL
832+
const counterBefore1 = counter;
833+
const user1 = new Parse.User();
834+
user1.setUsername('user1');
835+
user1.setPassword('pass1');
836+
user1.setEmail('user1@example.com');
837+
await user1.signUp();
838+
await jasmine.timeout();
839+
expect(emailCalls.length).toEqual(1);
840+
expect(emailCalls[0]).toContain(`https://example.com/${counterBefore1 + 1}`);
841+
842+
// Should trigger verification email with updated publicServerURL
843+
const counterBefore2 = counter;
844+
const user2 = new Parse.User();
845+
user2.setUsername('user2');
846+
user2.setPassword('pass2');
847+
user2.setEmail('user2@example.com');
848+
await user2.signUp();
849+
await jasmine.timeout();
850+
expect(emailCalls.length).toEqual(2);
851+
expect(emailCalls[1]).toContain(`https://example.com/${counterBefore2 + 1}`);
852+
expect(counterBefore2).toBeGreaterThan(counterBefore1);
853+
});
854+
});
688855
});

src/Config.js

Lines changed: 66 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ function removeTrailingSlash(str) {
3232
return str;
3333
}
3434

35+
/**
36+
* Config keys that need to be loaded asynchronously.
37+
*/
38+
const asyncKeys = ['publicServerURL'];
39+
3540
export class Config {
3641
static get(applicationId: string, mount: string) {
3742
const cacheInfo = AppCache.get(applicationId);
@@ -56,9 +61,42 @@ export class Config {
5661
return config;
5762
}
5863

64+
async loadKeys() {
65+
await Promise.all(
66+
asyncKeys.map(async key => {
67+
if (typeof this[`_${key}`] === 'function') {
68+
try {
69+
this[key] = await this[`_${key}`]();
70+
} catch (error) {
71+
throw new Error(`Failed to resolve async config key '${key}': ${error.message}`);
72+
}
73+
}
74+
})
75+
);
76+
77+
const cachedConfig = AppCache.get(this.appId);
78+
if (cachedConfig) {
79+
const updatedConfig = { ...cachedConfig };
80+
asyncKeys.forEach(key => {
81+
updatedConfig[key] = this[key];
82+
});
83+
AppCache.put(this.appId, updatedConfig);
84+
}
85+
}
86+
87+
static transformConfiguration(serverConfiguration) {
88+
for (const key of Object.keys(serverConfiguration)) {
89+
if (asyncKeys.includes(key) && typeof serverConfiguration[key] === 'function') {
90+
serverConfiguration[`_${key}`] = serverConfiguration[key];
91+
delete serverConfiguration[key];
92+
}
93+
}
94+
}
95+
5996
static put(serverConfiguration) {
6097
Config.validateOptions(serverConfiguration);
6198
Config.validateControllers(serverConfiguration);
99+
Config.transformConfiguration(serverConfiguration);
62100
AppCache.put(serverConfiguration.appId, serverConfiguration);
63101
Config.setupPasswordValidator(serverConfiguration.passwordPolicy);
64102
return serverConfiguration;
@@ -115,11 +153,7 @@ export class Config {
115153
throw 'extendSessionOnUse must be a boolean value';
116154
}
117155

118-
if (publicServerURL) {
119-
if (!publicServerURL.startsWith('http://') && !publicServerURL.startsWith('https://')) {
120-
throw 'publicServerURL should be a valid HTTPS URL starting with https://';
121-
}
122-
}
156+
this.validatePublicServerURL({ publicServerURL });
123157
this.validateSessionConfiguration(sessionLength, expireInactiveSessions);
124158
this.validateIps('masterKeyIps', masterKeyIps);
125159
this.validateIps('maintenanceKeyIps', maintenanceKeyIps);
@@ -154,6 +188,7 @@ export class Config {
154188
userController,
155189
appName,
156190
publicServerURL,
191+
_publicServerURL,
157192
emailVerifyTokenValidityDuration,
158193
emailVerifyTokenReuseIfValid,
159194
}) {
@@ -162,7 +197,7 @@ export class Config {
162197
this.validateEmailConfiguration({
163198
emailAdapter,
164199
appName,
165-
publicServerURL,
200+
publicServerURL: publicServerURL || _publicServerURL,
166201
emailVerifyTokenValidityDuration,
167202
emailVerifyTokenReuseIfValid,
168203
});
@@ -432,6 +467,30 @@ export class Config {
432467
}
433468
}
434469

470+
static validatePublicServerURL({ publicServerURL, required = false }) {
471+
if (!publicServerURL) {
472+
if (!required) {
473+
return;
474+
}
475+
throw 'The option publicServerURL is required.';
476+
}
477+
478+
const type = typeof publicServerURL;
479+
480+
if (type === 'string') {
481+
if (!publicServerURL.startsWith('http://') && !publicServerURL.startsWith('https://')) {
482+
throw 'The option publicServerURL must be a valid URL starting with http:// or https://.';
483+
}
484+
return;
485+
}
486+
487+
if (type === 'function') {
488+
return;
489+
}
490+
491+
throw `The option publicServerURL must be a string or function, but got ${type}.`;
492+
}
493+
435494
static validateEmailConfiguration({
436495
emailAdapter,
437496
appName,
@@ -445,9 +504,7 @@ export class Config {
445504
if (typeof appName !== 'string') {
446505
throw 'An app name is required for e-mail verification and password resets.';
447506
}
448-
if (typeof publicServerURL !== 'string') {
449-
throw 'A public server url is required for e-mail verification and password resets.';
450-
}
507+
this.validatePublicServerURL({ publicServerURL, required: true });
451508
if (emailVerifyTokenValidityDuration) {
452509
if (isNaN(emailVerifyTokenValidityDuration)) {
453510
throw 'Email verify token validity duration must be a valid number.';
@@ -757,7 +814,6 @@ export class Config {
757814
return this.masterKey;
758815
}
759816

760-
761817
// TODO: Remove this function once PagesRouter replaces the PublicAPIRouter;
762818
// the (default) endpoint has to be defined in PagesRouter only.
763819
get pagesEndpoint() {

src/Options/Definitions.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -495,7 +495,8 @@ module.exports.ParseServerOptions = {
495495
},
496496
publicServerURL: {
497497
env: 'PARSE_PUBLIC_SERVER_URL',
498-
help: 'Public URL to your parse server with http:// or https://.',
498+
help:
499+
'Optional. The public URL to Parse Server. This URL will be used to reach Parse Server publicly for features like password reset and email verification links. The option can be set to a string or a function that can be asynchronously resolved. The returned URL string must start with `http://` or `https://`.',
499500
},
500501
push: {
501502
env: 'PARSE_SERVER_PUSH',

src/Options/docs.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/Options/index.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -226,9 +226,9 @@ export interface ParseServerOptions {
226226
/* If set to `true`, a `Parse.Object` that is in the payload when calling a Cloud Function will be converted to an instance of `Parse.Object`. If `false`, the object will not be converted and instead be a plain JavaScript object, which contains the raw data of a `Parse.Object` but is not an actual instance of `Parse.Object`. Default is `false`. <br><br>ℹ️ The expected behavior would be that the object is converted to an instance of `Parse.Object`, so you would normally set this option to `true`. The default is `false` because this is a temporary option that has been introduced to avoid a breaking change when fixing a bug where JavaScript objects are not converted to actual instances of `Parse.Object`.
227227
:DEFAULT: true */
228228
encodeParseObjectInCloudFunction: ?boolean;
229-
/* Public URL to your parse server with http:// or https://.
229+
/* Optional. The public URL to Parse Server. This URL will be used to reach Parse Server publicly for features like password reset and email verification links. The option can be set to a string or a function that can be asynchronously resolved. The returned URL string must start with `http://` or `https://`.
230230
:ENV: PARSE_PUBLIC_SERVER_URL */
231-
publicServerURL: ?string;
231+
publicServerURL: ?(string | (() => string) | (() => Promise<string>));
232232
/* The options for pages such as password reset and email verification.
233233
:DEFAULT: {} */
234234
pages: ?PagesOptions;

src/Routers/UsersRouter.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -418,7 +418,7 @@ export class UsersRouter extends ClassesRouter {
418418
Config.validateEmailConfiguration({
419419
emailAdapter: req.config.userController.adapter,
420420
appName: req.config.appName,
421-
publicServerURL: req.config.publicServerURL,
421+
publicServerURL: req.config.publicServerURL || req.config._publicServerURL,
422422
emailVerifyTokenValidityDuration: req.config.emailVerifyTokenValidityDuration,
423423
emailVerifyTokenReuseIfValid: req.config.emailVerifyTokenReuseIfValid,
424424
});

src/middlewares.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@ export async function handleParseHeaders(req, res, next) {
213213
});
214214
return;
215215
}
216+
await config.loadKeys();
216217

217218
info.app = AppCache.get(info.appId);
218219
req.config = config;

types/Options/index.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ export interface ParseServerOptions {
8585
cacheAdapter?: Adapter<CacheAdapter>;
8686
emailAdapter?: Adapter<MailAdapter>;
8787
encodeParseObjectInCloudFunction?: boolean;
88-
publicServerURL?: string;
88+
publicServerURL?: string | (() => string) | (() => Promise<string>);
8989
pages?: PagesOptions;
9090
customPages?: CustomPagesOptions;
9191
liveQuery?: LiveQueryOptions;

types/ParseServer.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ declare class ParseServer {
2626
* @returns {Promise<void>} a promise that resolves when the server is stopped
2727
*/
2828
handleShutdown(): Promise<void>;
29+
/**
30+
* @static
31+
* Allow developers to customize each request with inversion of control/dependency injection
32+
*/
33+
static applyRequestContextMiddleware(api: any, options: any): void;
2934
/**
3035
* @static
3136
* Create an express app for the parse server

0 commit comments

Comments
 (0)