Skip to content

Add custom routes to pages router #7231

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
dc9f90d
Merge commit 'ccb045b68c5b4d983a90fa125513fc476e4e2387'
mtrezza Nov 19, 2020
4d72525
Merge remote-tracking branch 'upstream/master'
mtrezza Dec 4, 2020
65a6bdb
Merge remote-tracking branch 'upstream/master'
mtrezza Dec 5, 2020
50274b8
Merge remote-tracking branch 'upstream/master'
mtrezza Dec 8, 2020
3b337cd
Merge remote-tracking branch 'upstream/master'
mtrezza Dec 15, 2020
073f0fc
Merge remote-tracking branch 'upstream/master'
mtrezza Dec 18, 2020
2ee5907
Merge remote-tracking branch 'upstream/master'
mtrezza Dec 19, 2020
241a1d8
Merge remote-tracking branch 'upstream/master'
mtrezza Dec 26, 2020
4f097ce
Merge remote-tracking branch 'upstream/master'
mtrezza Jan 14, 2021
8f3ea1c
Merge remote-tracking branch 'upstream/master'
mtrezza Jan 23, 2021
4743cbc
Merge remote-tracking branch 'upstream/master'
mtrezza Jan 28, 2021
59de429
Merge remote-tracking branch 'upstream/master'
mtrezza Feb 1, 2021
ed2944f
Merge remote-tracking branch 'upstream/master'
mtrezza Feb 2, 2021
3ff82c8
Merge commit '7f47b0427ea56214d9b0199f0fcfa4af38794e02'
mtrezza Feb 9, 2021
19d7556
Merge remote-tracking branch 'upstream/master'
mtrezza Feb 12, 2021
935b913
Merge remote-tracking branch 'upstream/master'
mtrezza Feb 13, 2021
38f3739
Merge remote-tracking branch 'upstream/master'
mtrezza Feb 16, 2021
098a5c3
Merge remote-tracking branch 'upstream/master'
mtrezza Feb 21, 2021
669766a
Merge remote-tracking branch 'upstream/master'
mtrezza Feb 22, 2021
dcb91ee
Merge remote-tracking branch 'upstream/master'
mtrezza Feb 24, 2021
12395b9
added custom routes
mtrezza Feb 27, 2021
fd09d0e
fixed docs typos
mtrezza Feb 27, 2021
b9115cb
added page.customRoutes config validation
mtrezza Feb 27, 2021
8c7e3d4
added 404 response if missing custom route response
mtrezza Feb 27, 2021
164640e
added docs
mtrezza Feb 27, 2021
dc3a0ff
minor README formatting
mtrezza Feb 27, 2021
68dc593
added CHANGELOG entry
mtrezza Feb 27, 2021
d5aa98b
fixed bug in definitions builder that did not recognize array of cust…
mtrezza Mar 2, 2021
90a68e4
added missing route handler definition
mtrezza Mar 2, 2021
73290b6
fixed custom routes definition
mtrezza Mar 2, 2021
093af98
Merge remote-tracking branch 'upstream/master'
mtrezza Mar 2, 2021
8fde141
Merge branch 'master' into add-custom-routes-to-pages-router
mtrezza Mar 2, 2021
a5b27d0
Merge remote-tracking branch 'upstream/master'
mtrezza Mar 3, 2021
7935fd6
Merge branch 'master' into add-custom-routes-to-pages-router
mtrezza Mar 3, 2021
54ce26d
Merge branch 'master' into add-custom-routes-to-pages-router
mtrezza Mar 5, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@
__BREAKING CHANGES:__
- NEW: Added file upload restriction. File upload is now only allowed for authenticated users by default for improved security. To allow file upload also for Anonymous Users or Public, set the `fileUpload` parameter in the [Parse Server Options](https://parseplatform.org/parse-server/api/master/ParseServerOptions.html). [#7071](https://github.com/parse-community/parse-server/pull/7071). Thanks to [dblythy](https://github.com/dblythy), [Manuel Trezza](https://github.com/mtrezza).
___
- IMPROVE: Allow Cloud Validator `options` to be async [#7155](https://github.com/parse-community/parse-server/pull/7155). Thanks to [dblythy](https://github.com/dblythy)
- NEW (EXPERIMENTAL): Added new page router with placeholder rendering and localization of custom and feature pages such as password reset and email verification. **Caution, this is an experimental feature that may not be appropriate for production.** [#6891](https://github.com/parse-community/parse-server/issues/6891). Thanks to [Manuel Trezza](https://github.com/mtrezza).
- NEW (EXPERIMENTAL): Added custom routes to easily customize flows for password reset, email verification or build entirely new flows. **Caution, this is an experimental feature that may not be appropriate for production.** [#7231](https://github.com/parse-community/parse-server/issues/7231). Thanks to [Manuel Trezza](https://github.com/mtrezza).
- NEW: Added convenience method `Parse.Cloud.sendEmail(...)` to send email via email adapter in Cloud Code. [#7089](https://github.com/parse-community/parse-server/pull/7089). Thanks to [dblythy](https://github.com/dblythy)
- NEW: LiveQuery support for $and, $nor, $containedBy, $geoWithin, $geoIntersects queries [#7113](https://github.com/parse-community/parse-server/pull/7113). Thanks to [dplewis](https://github.com/dplewis)
- NEW: Supporting patterns in LiveQuery server's config parameter `classNames` [#7131](https://github.com/parse-community/parse-server/pull/7131). Thanks to [Nes-si](https://github.com/Nes-si)
Expand All @@ -29,6 +29,7 @@ ___
- IMPROVE: Allow Cloud Validator `options` to be async [#7155](https://github.com/parse-community/parse-server/pull/7155). Thanks to [dblythy](https://github.com/dblythy)
- IMPROVE: Optimize queries on classes with pointer permissions. [#7061](https://github.com/parse-community/parse-server/pull/7061). Thanks to [Pedro Diaz](https://github.com/pdiaz)
- IMPROVE: Parse Server will from now on be continuously tested against all relevant Postgres versions (minor versions). Added Postgres compatibility table to Parse Server docs. [#7176](https://github.com/parse-community/parse-server/pull/7176). Thanks to [Corey Baker](https://github.com/cbaker6).
- IMPROVE: Allow Cloud Validator `options` to be async [#7155](https://github.com/parse-community/parse-server/pull/7155). Thanks to [dblythy](https://github.com/dblythy)
- FIX: Fix error when a not yet inserted job is updated [#7196](https://github.com/parse-community/parse-server/pull/7196). Thanks to [Antonio Davi Macedo Coelho de Castro](https://github.com/davimacedo).
- FIX: request.context for afterFind triggers. [#7078](https://github.com/parse-community/parse-server/pull/7078). Thanks to [dblythy](https://github.com/dblythy)
- FIX: Winston Logger interpolating stdout to console [#7114](https://github.com/parse-community/parse-server/pull/7114). Thanks to [dplewis](https://github.com/dplewis)
Expand Down
84 changes: 72 additions & 12 deletions README.md

Large diffs are not rendered by default.

15 changes: 15 additions & 0 deletions public/custom_page.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<!--
This page demonstrates how to use custom pages in custom routes.
-->
<html>

<head>
<title>{{appName}}</title>
</head>

<body>
<h1>{{appName}}</h1>
</body>

</html>
3 changes: 2 additions & 1 deletion resources/buildConfigDefinitions.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ function getENVPrefix(iface) {
const options = {
'ParseServerOptions' : 'PARSE_SERVER_',
'PagesOptions' : 'PARSE_SERVER_PAGES_',
'PagesRoute': 'PARSE_SERVER_PAGES_ROUTE_',
'PagesCustomUrlsOptions' : 'PARSE_SERVER_PAGES_CUSTOM_URL_',
'CustomPagesOptions' : 'PARSE_SERVER_CUSTOM_PAGES_',
'LiveQueryServerOptions' : 'PARSE_LIVE_QUERY_SERVER_',
Expand Down Expand Up @@ -166,7 +167,7 @@ function parseDefaultValue(elt, value, t) {
if (type == 'NumberOrBoolean') {
literalValue = t.numericLiteral(parsers.numberOrBoolParser('')(value));
}
const literalTypes = ['Object', 'IdempotencyOptions','FileUploadOptions','CustomPagesOptions', 'PagesCustomUrlsOptions', 'PagesOptions'];
const literalTypes = ['Object', 'PagesRoute', 'IdempotencyOptions','FileUploadOptions','CustomPagesOptions', 'PagesCustomUrlsOptions', 'PagesOptions'];
if (literalTypes.includes(type)) {
const object = parsers.objectParser(value);
const props = Object.keys(object).map((key) => {
Expand Down
154 changes: 154 additions & 0 deletions spec/PagesRouter.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,9 @@ describe('Pages Router', () => {
expect(Config.get(Parse.applicationId).pages.customUrls).toBe(
Definitions.PagesOptions.customUrls.default
);
expect(Config.get(Parse.applicationId).pages.customRoutes).toBe(
Definitions.PagesOptions.customRoutes.default
);
});

it('throws on invalid configuration', async () => {
Expand Down Expand Up @@ -296,6 +299,10 @@ describe('Pages Router', () => {
{ localizationFallbackLocale: 0 },
{ localizationFallbackLocale: {} },
{ localizationFallbackLocale: [] },
{ customRoutes: true },
{ customRoutes: 0 },
{ customRoutes: 'a' },
{ customRoutes: {} },
];
for (const option of options) {
await expectAsync(reconfigureServerWithPagesConfig(option)).toBeRejected();
Expand Down Expand Up @@ -971,5 +978,152 @@ describe('Pages Router', () => {
expect(response.text).toBe('Not found.');
});
});

describe('custom route', () => {
it('handles custom route with GET', async () => {
config.pages.customRoutes = [
{
method: 'GET',
path: 'custom_page',
handler: async req => {
expect(req).toBeDefined();
expect(req.method).toBe('GET');
return { file: 'custom_page.html' };
},
},
];
await reconfigureServer(config);
const handlerSpy = spyOn(config.pages.customRoutes[0], 'handler').and.callThrough();

const url = `${config.publicServerURL}/apps/${config.appId}/custom_page`;
const response = await request({
url: url,
followRedirects: false,
}).catch(e => e);
expect(response.status).toBe(200);
expect(response.text).toMatch(config.appName);
expect(handlerSpy).toHaveBeenCalled();
});

it('handles custom route with POST', async () => {
config.pages.customRoutes = [
{
method: 'POST',
path: 'custom_page',
handler: async req => {
expect(req).toBeDefined();
expect(req.method).toBe('POST');
return { file: 'custom_page.html' };
},
},
];
const handlerSpy = spyOn(config.pages.customRoutes[0], 'handler').and.callThrough();
await reconfigureServer(config);

const url = `${config.publicServerURL}/apps/${config.appId}/custom_page`;
const response = await request({
url: url,
followRedirects: false,
method: 'POST',
}).catch(e => e);
expect(response.status).toBe(200);
expect(response.text).toMatch(config.appName);
expect(handlerSpy).toHaveBeenCalled();
});

it('handles multiple custom routes', async () => {
config.pages.customRoutes = [
{
method: 'GET',
path: 'custom_page',
handler: async req => {
expect(req).toBeDefined();
expect(req.method).toBe('GET');
return { file: 'custom_page.html' };
},
},
{
method: 'POST',
path: 'custom_page',
handler: async req => {
expect(req).toBeDefined();
expect(req.method).toBe('POST');
return { file: 'custom_page.html' };
},
},
];
const getHandlerSpy = spyOn(config.pages.customRoutes[0], 'handler').and.callThrough();
const postHandlerSpy = spyOn(config.pages.customRoutes[1], 'handler').and.callThrough();
await reconfigureServer(config);

const url = `${config.publicServerURL}/apps/${config.appId}/custom_page`;
const getResponse = await request({
url: url,
followRedirects: false,
method: 'GET',
}).catch(e => e);
expect(getResponse.status).toBe(200);
expect(getResponse.text).toMatch(config.appName);
expect(getHandlerSpy).toHaveBeenCalled();

const postResponse = await request({
url: url,
followRedirects: false,
method: 'POST',
}).catch(e => e);
expect(postResponse.status).toBe(200);
expect(postResponse.text).toMatch(config.appName);
expect(postHandlerSpy).toHaveBeenCalled();
});

it('handles custom route with async handler', async () => {
config.pages.customRoutes = [
{
method: 'GET',
path: 'custom_page',
handler: async req => {
expect(req).toBeDefined();
expect(req.method).toBe('GET');
const file = await new Promise(resolve =>
setTimeout(resolve('custom_page.html'), 1000)
);
return { file };
},
},
];
await reconfigureServer(config);
const handlerSpy = spyOn(config.pages.customRoutes[0], 'handler').and.callThrough();

const url = `${config.publicServerURL}/apps/${config.appId}/custom_page`;
const response = await request({
url: url,
followRedirects: false,
}).catch(e => e);
expect(response.status).toBe(200);
expect(response.text).toMatch(config.appName);
expect(handlerSpy).toHaveBeenCalled();
});

it('returns 404 if custom route does not return page', async () => {
config.pages.customRoutes = [
{
method: 'GET',
path: 'custom_page',
handler: async () => {},
},
];
await reconfigureServer(config);
const handlerSpy = spyOn(config.pages.customRoutes[0], 'handler').and.callThrough();

const url = `${config.publicServerURL}/apps/${config.appId}/custom_page`;
const response = await request({
url: url,
followRedirects: false,
}).catch(e => e);
expect(response.status).toBe(404);
expect(response.text).toMatch('Not found');
expect(handlerSpy).toHaveBeenCalled();
});
});
});
});
5 changes: 5 additions & 0 deletions src/Config.js
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,11 @@ export class Config {
} else if (Object.prototype.toString.call(pages.customUrls) !== '[object Object]') {
throw 'Parse Server option pages.customUrls must be an object.';
}
if (pages.customRoutes === undefined) {
pages.customRoutes = PagesOptions.customRoutes.default;
} else if (!(pages.customRoutes instanceof Array)) {
throw 'Parse Server option pages.customRoutes must be an array.';
}
}

static validateIdempotencyOptions(idempotencyOptions) {
Expand Down
18 changes: 18 additions & 0 deletions src/Options/Definitions.js
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,12 @@ module.exports.ParseServerOptions = {
},
};
module.exports.PagesOptions = {
customRoutes: {
env: 'PARSE_SERVER_PAGES_CUSTOM_ROUTES',
help: 'The custom routes.',
action: parsers.arrayParser,
default: [],
},
customUrls: {
env: 'PARSE_SERVER_PAGES_CUSTOM_URLS',
help: 'The URLs to the custom pages.',
Expand Down Expand Up @@ -481,6 +487,18 @@ module.exports.PagesOptions = {
default: {},
},
};
module.exports.PagesRoute = {
method: {
env: 'PARSE_SERVER_PAGES_ROUTE_METHOD',
help: "The route method, e.g. 'GET' or 'POST'.",
required: true,
},
path: {
env: 'PARSE_SERVER_PAGES_ROUTE_PATH',
help: 'The route path.',
required: true,
},
};
module.exports.PagesCustomUrlsOptions = {
emailVerificationLinkExpired: {
env: 'PARSE_SERVER_PAGES_CUSTOM_URL_EMAIL_VERIFICATION_LINK_EXPIRED',
Expand Down
7 changes: 7 additions & 0 deletions src/Options/docs.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@

/**
* @interface PagesOptions
* @property {Generic[]} customRoutes The custom routes.
* @property {PagesCustomUrlsOptions} customUrls The URLs to the custom pages.
* @property {Boolean} enableLocalization Is true if pages should be localized; this has no effect on custom page redirects.
* @property {Boolean} enableRouter Is true if the pages router should be enabled; this is required for any of the pages options to take effect. Caution, this is an experimental feature that may not be appropriate for production.
Expand All @@ -93,6 +94,12 @@
* @property {Object} placeholders The placeholder keys and values which will be filled in pages; this can be a simple object or a callback function.
*/

/**
* @interface PagesRoute
* @property {String} method The route method, e.g. 'GET' or 'POST'.
* @property {String} path The route path.
*/

/**
* @interface PagesCustomUrlsOptions
* @property {String} emailVerificationLinkExpired The URL to the custom page for email verification -> link expired.
Expand Down
10 changes: 10 additions & 0 deletions src/Options/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,16 @@ export interface PagesOptions {
/* The URLs to the custom pages.
:DEFAULT: {} */
customUrls: ?PagesCustomUrlsOptions;
/* The custom routes.
:DEFAULT: [] */
customRoutes: ?(PagesRoute[]);
}

export interface PagesRoute {
/* The route path. */
path: string;
/* The route method, e.g. 'GET' or 'POST'. */
method: string;
}

export interface PagesCustomUrlsOptions {
Expand Down
32 changes: 30 additions & 2 deletions src/Routers/PagesRouter.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ export class PagesRouter extends PromiseRouter {
: path.resolve(__dirname, '../../public');
this.loadJsonResource();
this.mountPagesRoutes();
this.mountCustomRoutes();
this.mountStaticRoute();
}

verifyEmail(req) {
Expand Down Expand Up @@ -283,7 +285,7 @@ export class PagesRouter extends PromiseRouter {

// Add locale to params to ensure it is passed on with every request;
// that means, once a locale is set, it is passed on to any follow-up page,
// e.g. request_password_reset -> password_reset -> passwort_reset_success
// e.g. request_password_reset -> password_reset -> password_reset_success
const locale = this.getLocale(req);
params[pageParams.locale] = locale;

Expand Down Expand Up @@ -563,7 +565,7 @@ export class PagesRouter extends PromiseRouter {
}

/**
* Creates a response with http rediret.
* Creates a response with http redirect.
* @param {Object} req The express request.
* @param {String} path The path of the file to return.
* @param {Object} params The query parameters to include.
Expand Down Expand Up @@ -696,7 +698,33 @@ export class PagesRouter extends PromiseRouter {
return this.requestResetPassword(req);
}
);
}

mountCustomRoutes() {
for (const route of this.pagesConfig.customRoutes || []) {
this.route(
route.method,
`/${this.pagesEndpoint}/:appId/${route.path}`,
req => {
this.setConfig(req);
},
async req => {
const { file, query = {} } = (await route.handler(req)) || {};

// If route handler did not return a page send 404 response
if (!file) {
return this.notFound();
}

// Send page response
const page = new Page({ id: file, defaultFile: file });
return this.goToPage(req, page, query, false);
}
);
}
}

mountStaticRoute() {
this.route(
'GET',
`/${this.pagesEndpoint}/(*)?`,
Expand Down