jenkins-bot has submitted this change and it was merged. ( 
https://gerrit.wikimedia.org/r/397859 )

Change subject: Update: add query parameter parser
......................................................................


Update: add query parameter parser

Bug: T178617
Change-Id: I91c5111d5b0cdda758d1f19971fe7001a052148a
---
M package-lock.json
M package.json
M src/client/index.tsx
M src/common/pages/home.tsx
M src/common/router/route.ts
M src/common/router/router.test.ts
M src/common/router/router.ts
M src/common/router/routes.test.ts
M src/server/index.tsx
9 files changed, 154 insertions(+), 94 deletions(-)

Approvals:
  Jhernandez: Looks good to me, approved
  jenkins-bot: Verified



diff --git a/package-lock.json b/package-lock.json
index be6cd49..ab97e9b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -119,6 +119,12 @@
         "@types/node": "8.0.47"
       }
     },
+    "@types/query-string": {
+      "version": "5.0.1",
+      "resolved": 
"https://registry.npmjs.org/@types/query-string/-/query-string-5.0.1.tgz";,
+      "integrity": 
"sha512-qlH3YtJ5ipp6z5SU9G0LJsBnNEZqTKPeGjNIyMcGD5Dhbooz0CCsW/sILsgb+LBRrDeTehRtQviXV2kfLm3dqg==",
+      "dev": true
+    },
     "@types/serve-static": {
       "version": "1.7.32",
       "resolved": 
"https://registry.npmjs.org/@types/serve-static/-/serve-static-1.7.32.tgz";,
@@ -1586,6 +1592,11 @@
       "resolved": 
"https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz";,
       "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=",
       "dev": true
+    },
+    "decode-uri-component": {
+      "version": "0.2.0",
+      "resolved": 
"https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz";,
+      "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU="
     },
     "deep-equal": {
       "version": "1.0.1",
@@ -4793,6 +4804,18 @@
         "prepend-http": "1.0.4",
         "query-string": "4.3.4",
         "sort-keys": "1.1.2"
+      },
+      "dependencies": {
+        "query-string": {
+          "version": "4.3.4",
+          "resolved": 
"https://registry.npmjs.org/query-string/-/query-string-4.3.4.tgz";,
+          "integrity": "sha1-u7aTucqRXCMlFbIosaArYJBD2+s=",
+          "dev": true,
+          "requires": {
+            "object-assign": "4.1.1",
+            "strict-uri-encode": "1.1.0"
+          }
+        }
       }
     },
     "npm-run-all": {
@@ -4873,8 +4896,7 @@
     "object-assign": {
       "version": "4.1.1",
       "resolved": 
"https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz";,
-      "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=",
-      "dev": true
+      "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM="
     },
     "object-keys": {
       "version": "1.0.11",
@@ -6064,11 +6086,11 @@
       "integrity": 
"sha512-eRzhrN1WSINYCDCbrz796z37LOe3m5tmW7RQf6oBntukAG1nmovJvhnwHHRMAfeoItc1m2Hk02WER2aQ/iqs+A=="
     },
     "query-string": {
-      "version": "4.3.4",
-      "resolved": 
"https://registry.npmjs.org/query-string/-/query-string-4.3.4.tgz";,
-      "integrity": "sha1-u7aTucqRXCMlFbIosaArYJBD2+s=",
-      "dev": true,
+      "version": "5.0.1",
+      "resolved": 
"https://registry.npmjs.org/query-string/-/query-string-5.0.1.tgz";,
+      "integrity": 
"sha512-aM+MkQClojlNiKkO09tiN2Fv8jM/L7GWIjG2liWeKljlOdOPNWr+bW3KQ+w5V/uKprpezC7fAsAMsJtJ+2rLKA==",
       "requires": {
+        "decode-uri-component": "0.2.0",
         "object-assign": "4.1.1",
         "strict-uri-encode": "1.1.0"
       }
@@ -6936,8 +6958,7 @@
     "strict-uri-encode": {
       "version": "1.1.0",
       "resolved": 
"https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz";,
-      "integrity": "sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM=",
-      "dev": true
+      "integrity": "sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM="
     },
     "string-width": {
       "version": "2.1.1",
diff --git a/package.json b/package.json
index 259a75e..bd3f051 100644
--- a/package.json
+++ b/package.json
@@ -51,7 +51,8 @@
     "isomorphic-unfetch": "2.0.0",
     "path-to-regexp": "2.1.0",
     "preact": "8.2.6",
-    "preact-render-to-string": "3.7.0"
+    "preact-render-to-string": "3.7.0",
+    "query-string": "5.0.1"
   },
   "devDependencies": {
     "@types/assets-webpack-plugin": "3.5.0",
@@ -64,6 +65,7 @@
     "@types/mocha": "2.2.44",
     "@types/node": "8.0.47",
     "@types/node-fetch": "1.6.7",
+    "@types/query-string": "5.0.1",
     "@types/sinon": "4.0.0",
     "@types/webpack-node-externals": "1.6.0",
     "assets-webpack-plugin": "3.5.1",
@@ -112,7 +114,7 @@
     },
     {
       "path": "dist/public/vendor.*.js",
-      "maxSize": "8.2KB"
+      "maxSize": "9.6KB"
     },
     {
       "path": "dist/public/pages/about.*.js",
diff --git a/src/client/index.tsx b/src/client/index.tsx
index 72b596d..ea10080 100644
--- a/src/client/index.tsx
+++ b/src/client/index.tsx
@@ -1,5 +1,6 @@
 import { h, render } from "preact";
 import newHistory from "history/createBrowserHistory";
+import { Location as HistoricalLocation } from "history";
 import "wikimedia-ui-base/wikimedia-ui-base.css";
 import "./index.css";
 import { RouteResponse, newRouter } from "../common/router/router";
@@ -11,11 +12,8 @@
 // Include preact/debug only in development for React DevTools integration.
 // This check needs to be as defined with DefinePlugin in the webpack config so
 // that Uglify can remove this if statement in production.
-if (process.env.NODE_ENV !== "production") {
-  require("preact/debug");
-}
+if (process.env.NODE_ENV !== "production") require("preact/debug");
 
-const ssrData: SSRData = (window as any).__SSR_DATA__;
 const history = newHistory();
 const router = newRouter(routes);
 const pageRoot = (_ => {
@@ -39,13 +37,13 @@
   );
 }
 
-function route(path: string) {
-  router.route(path).then(renderPageRoot);
+function route(location: Location | HistoricalLocation) {
+  router.route(location.pathname, location.search).then(renderPageRoot);
 }
 
 // Observe the History
 history.listen((location, action) => {
-  route(location.pathname);
+  route(location);
 
   if (action === "PUSH" || action === "REPLACE") {
     // A new destination, reset the window scroll state.
@@ -56,6 +54,5 @@
 // Replace the server rendered root, which does not include CSS, with a styled
 // page that manages navigation with History. This enables the single page app
 // experience.
-if (!ssrData.forceSSR) {
-  route(location.pathname);
-}
+const ssrData: SSRData = (window as any).__SSR_DATA__;
+if (!ssrData.forceSSR) route(window.location);
diff --git a/src/common/pages/home.tsx b/src/common/pages/home.tsx
index 24b77c2..2eea50a 100644
--- a/src/common/pages/home.tsx
+++ b/src/common/pages/home.tsx
@@ -89,7 +89,10 @@
               }) => (
                 <li>
                   <Link
-                    href={wiki.toPath({ path: { title, revision }, query })}
+                    href={wiki.toPathQuery({
+                      path: { title, revision },
+                      query
+                    })}
                   >
                     {text}
                   </Link>
@@ -116,7 +119,7 @@
                 text: string;
               }) => (
                 <li>
-                  <Link href={summary.toPath({ path: { title }, query })}>
+                  <Link href={summary.toPathQuery({ path: { title }, query })}>
                     {text}
                   </Link>
                 </li>
diff --git a/src/common/router/route.ts b/src/common/router/route.ts
index 4b64238..03394f1 100644
--- a/src/common/router/route.ts
+++ b/src/common/router/route.ts
@@ -1,4 +1,5 @@
 import * as pathToRegExp from "path-to-regexp";
+import * as queryString from "query-string";
 import { AnyComponent } from "preact";
 import HttpResponse from "../http/http-response";
 
@@ -75,14 +76,21 @@
    * the path does not match. An empty object is returned for matching
    * NoPropsRoutes.
    */
-  toParams(pathQuery: string): Params | void; // eslint-disable-line 
no-use-before-define
+  toParams(path: string, query?: string): Params | void; // 
eslint-disable-line no-use-before-define
 
   /** Generates a URL path from Params. */
-  toPath(params: Params): string; // eslint-disable-line no-use-before-define
+  toPath(params: Params): string;
+
+  /** Generates a URL query string from Params. */
+  toQuery(params: Params): string;
+
+  toPathQuery(params: Params): string;
 }
 
 export interface NoParamsRoute extends Route<undefined> {
-  toPath(params?: undefined): string; // eslint-disable-line 
no-use-before-define
+  toPath(params?: undefined): string;
+  toQuery(params?: undefined): string;
+  toPathQuery(params?: undefined): string;
 }
 
 /**
@@ -97,38 +105,23 @@
 function toParams(
   pathRegExp: RegExp,
   paramNames: pathToRegExp.Key[],
-  pathQuery: string
+  path: string,
+  query: string = ""
 ) {
-  const matches = pathRegExp.exec(pathQuery);
+  const matches = pathRegExp.exec(path);
   if (matches) {
     const [, ...paramValues] = matches;
+    const queryParams = queryString.parse(query);
     const params = {
       path: paramNames.length ? {} as PathParams : undefined,
-      query: undefined
+      query: Object.keys(queryParams).length ? queryParams : undefined
     };
     paramNames.forEach((param, index) => {
       if (params.path) params.path[param.name] = paramValues[index];
     });
-    // todo: query parsing.
     return params;
   }
   return undefined;
-}
-
-function toPath(
-  pathFunction: pathToRegExp.PathFunction,
-  params?: Partial<RouteParams>
-) {
-  const path = pathFunction((params && params.path) || undefined);
-  let query = "";
-  if (params && params.query && Object.keys(params.query)) {
-    // todo: real query string assembly.
-    query = "?";
-    Object.keys(params.query).forEach(name => {
-      query += `${name}=${params.query && params.query[name]}&`;
-    });
-  }
-  return `${path}${query}`;
 }
 
 export function newRoute<Params extends Partial<RouteParams> | undefined>({
@@ -143,10 +136,20 @@
     { endsWith: "?" }
   );
   const pathFunction = pathToRegExp.compile(path);
+  const toPath = (params: Params) =>
+    pathFunction(params ? params.path : undefined);
+  const toQuery = (params: Params) =>
+    queryString.stringify((params && params.query) || {});
+
   return {
     path,
     page,
-    toParams: pathQuery => toParams(pathRegExp, paramNames, pathQuery),
-    toPath: params => toPath(pathFunction, params)
+    toParams: (path, query) => toParams(pathRegExp, paramNames, path, query),
+    toPath,
+    toQuery,
+    toPathQuery: params => {
+      const query = toQuery(params);
+      return `${toPath(params)}${query ? `?${query}` : ""}`;
+    }
   } as Route<Params>;
 }
diff --git a/src/common/router/router.test.ts b/src/common/router/router.test.ts
index 2e00894..4508ce9 100644
--- a/src/common/router/router.test.ts
+++ b/src/common/router/router.test.ts
@@ -35,6 +35,28 @@
         });
     });
 
+    it("populates path and query parameters", () => {
+      const module = {
+        default: {
+          getInitialProps(params: any) {
+            assert.deepEqual(params, {
+              path: { title: "Title" },
+              query: { query: "param" }
+            });
+            return Promise.resolve({ status: 200, data: undefined });
+          },
+          Component: () => null
+        }
+      };
+      const routes = [newRoute({ path: "/wiki/:title", page: "wiki" })];
+
+      return newRouter(routes, () => Promise.resolve(module))
+        .route("/wiki/Title", "?query=param")
+        .then(rsp => {
+          assert.deepEqual(rsp.status, 200);
+        });
+    });
+
     it("resolves an unknown route as a 404", () => {
       return newRouter(routes, requestPageModule)
         .route("/404")
diff --git a/src/common/router/router.ts b/src/common/router/router.ts
index b5fc5f4..cb3cfd8 100644
--- a/src/common/router/router.ts
+++ b/src/common/router/router.ts
@@ -1,5 +1,10 @@
 import { AnyComponent } from "preact";
-import { PageComponent, PageModule, Route } from "../../common/router/route";
+import {
+  PageComponent,
+  PageModule,
+  Route,
+  RouteParams
+} from "../../common/router/route";
 import HttpResponse from "../http/http-response";
 import notFoundPage, { Props as NotFoundProps } from "../pages/not-found";
 import errorPage, { Props as ErrorProps } from "../pages/error";
@@ -13,11 +18,11 @@
   props: Props;
 }
 
-interface RequestPageModule<Params, Props> {
-  (name: string): Promise<PageModule<Params, Props>>;
+interface RequestPageModule {
+  (name: string): Promise<PageModule<Partial<RouteParams>, any>>;
 }
 
-function getInitialProps<Params, Props>(
+function getInitialProps<Params extends Partial<RouteParams>, Props>(
   module: PageComponent<Params, Props>,
   params: Params
 ): Promise<HttpResponse<Props> | void> {
@@ -32,12 +37,14 @@
  * @param {string} page The page chunk basename with no extension. Corresponds
  *                      to Route.page.
  */
-function requestPageModuleChunk(page: string): Promise<PageModule<any, any>> {
+function requestPageModuleChunk(
+  page: string
+): Promise<PageModule<Partial<RouteParams>, any>> {
   return import(/* webpackChunkName: "pages/[request]" */ `../pages/${page}`);
 }
 
-function respond<Params, Props>(
-  requestPageModule: RequestPageModule<Params, Props>,
+function respond<Params extends Partial<RouteParams>, Props>(
+  requestPageModule: RequestPageModule,
   route: Route<Params>,
   params: Params
 ): Promise<RouteResponse<Props>> {
@@ -94,12 +101,13 @@
 
 export function newRouter(
   routes: Route<any>[],
-  requestPageModule: RequestPageModule<any, any> = requestPageModuleChunk
+  requestPageModule: RequestPageModule = requestPageModuleChunk
 ) {
   return {
-    route(pathQuery: string): Promise<RouteResponse<any>> {
+    route(path: string, query?: string): Promise<RouteResponse<any>> {
+      const pathQuery = `${path}${query || ""}`;
       for (const route of routes) {
-        const params = route.toParams(pathQuery);
+        const params = route.toParams(path, query);
         if (params) {
           return respond(requestPageModule, route, params).catch(error =>
             respondError(pathQuery, error)
diff --git a/src/common/router/routes.test.ts b/src/common/router/routes.test.ts
index f14423e..e1ec911 100644
--- a/src/common/router/routes.test.ts
+++ b/src/common/router/routes.test.ts
@@ -8,7 +8,7 @@
   randomSummary,
   styleGuide
 } from "./routes";
-import { PathParams, Route, RouteParams, QueryParams } from "./route";
+import { Route, RouteParams, PathParams, QueryParams } from "./route";
 
 interface TestParams<Params extends Partial<RouteParams> | undefined> {
   name: string;
@@ -18,56 +18,48 @@
   // passed an unescaped string, it's worth manually assembling an unescaped
   // path to test this scenario.
   path: string;
+  query?: string;
   params: Params;
 }
 
-function testPathParams({ name, route, path, params }: TestParams<any>) {
+function testParams({ name, route, path, query, params }: TestParams<any>) {
   it(`${name} path and route parameter types are in sync`, () => {
-    const expected = {
-      path: params && params.path ? {} as PathParams : undefined,
-      query: params && params.query ? {} as QueryParams : undefined
+    const expected: Partial<RouteParams> = {
+      path: undefined,
+      query: undefined
     };
-    if (params) {
-      Object.keys(params.path || {}).forEach(name => {
-        const value = params.path[name];
-        if (expected.path) {
-          expected.path[name] =
-            value === undefined ? undefined : encodeURIComponent(value);
-        }
+    if (params && params.path) {
+      expected.path = {};
+      Object.keys(params.path).forEach(name => {
+        const param = params.path[name];
+        (expected.path as PathParams)[name] =
+          param === undefined ? undefined : encodeURIComponent(param);
       });
-
-      Object.keys(params.query || {}).forEach(name => {
-        const value = params.query[name];
-        if (expected.query) {
-          expected.query[name] =
-            value === undefined ? undefined : encodeURIComponent(value);
-        }
+    }
+    if (params && params.query) {
+      // query-string objects do not have a prototype.
+      expected.query = Object.create(null);
+      Object.keys(params.query).forEach(name => {
+        const param = params.query[name];
+        (expected.query as QueryParams)[name] =
+          param === undefined ? undefined : encodeURIComponent(param);
       });
     }
 
     const path = route.toPath(params);
-    const result = route.toParams(path);
+    const query = route.toQuery(params);
+    const result = route.toParams(path, `?${query}`);
     assert.deepStrictEqual(result, expected);
   });
 
   it(`${name} unescaped path matches`, () => {
-    const expected = {
-      path: params && params.path ? {} as PathParams : undefined,
-      query: params && params.query ? {} as QueryParams : undefined
-    };
-    if (params) {
-      Object.keys(params.path || {}).forEach(name => {
-        const value = params.path[name];
-        if (expected.path) expected.path[name] = value;
-      });
-
-      Object.keys(params.query || {}).forEach(name => {
-        const value = params.query[name];
-        if (expected.query) expected.query[name] = value;
-      });
+    const expected = { path: undefined, query: undefined, ...params };
+    if (expected.query) {
+      // query-string objects do not have a prototype.
+      expected.query = Object.assign(Object.create(null), expected.query);
     }
 
-    const result = route.toParams(path);
+    const result = route.toParams(path, query ? `?${query}` : "");
     assert.deepStrictEqual(result, expected);
   });
 }
@@ -78,6 +70,13 @@
       // Note: these types are verified to be RouteParams but not verified
       //       to be their specific route's type.
       { name: "home", route: home, path: "/", params: undefined },
+      {
+        name: "home (global route parameters)",
+        route: home,
+        path: "/",
+        query: "query=param",
+        params: { path: undefined, query: { query: "param" } }
+      },
       { name: "about", route: about, path: "/about", params: undefined },
       {
         name: "wiki (title)",
@@ -233,7 +232,7 @@
         path: "/dev/style-guide",
         params: undefined
       }
-    ].forEach(testPathParams);
+    ].forEach(testParams);
   });
 
   describe("wiki", () => {
diff --git a/src/server/index.tsx b/src/server/index.tsx
index efb75bb..ce88287 100644
--- a/src/server/index.tsx
+++ b/src/server/index.tsx
@@ -1,5 +1,6 @@
 import * as express from "express";
 import * as compression from "compression";
+import * as queryString from "query-string";
 import { h } from "preact";
 import { render as renderToString } from "preact-render-to-string";
 import { RouteResponse, newRouter } from "../common/router/router";
@@ -15,6 +16,10 @@
 // Disable useless header.
 // 
http://expressjs.com/en/advanced/best-practice-security.html#at-a-minimum-disable-x-powered-by-header
 server.disable("x-powered-by");
+
+// Perform manually query parsing using query-string. Request.query is now
+// always an empty object.
+server.set("query parser", false);
 
 server.use(compression());
 
@@ -36,7 +41,7 @@
 const router = newRouter(routes);
 server.get("*", (request, response) => {
   router
-    .route(request.url)
+    .route(request.path, queryString.extract(request.url))
     .then(routeResponse =>
       response.status(routeResponse.status).send(render(routeResponse))
     )

-- 
To view, visit https://gerrit.wikimedia.org/r/397859
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings

Gerrit-MessageType: merged
Gerrit-Change-Id: I91c5111d5b0cdda758d1f19971fe7001a052148a
Gerrit-PatchSet: 3
Gerrit-Project: marvin
Gerrit-Branch: master
Gerrit-Owner: Niedzielski <[email protected]>
Gerrit-Reviewer: Jhernandez <[email protected]>
Gerrit-Reviewer: Sniedzielski <[email protected]>
Gerrit-Reviewer: jenkins-bot <>

_______________________________________________
MediaWiki-commits mailing list
[email protected]
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits

Reply via email to