Niedzielski has uploaded a new change for review. (
https://gerrit.wikimedia.org/r/396464 )
Change subject: Chore: add query parameter typing
......................................................................
Chore: add query parameter typing
• Refactor route#RouteParams into path and query components.
• Update the tests and home page links.
• Query string marshalling and unmarshalling will come in T178617.
Bug: T178615
Bug: T178617
Change-Id: I8f07f06354ffdb459e651958cd0da49a07f23b93
---
M package.json
M src/common/pages/home.tsx
M src/common/pages/not-found.tsx
M src/common/pages/summary.tsx
M src/common/pages/wiki.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
9 files changed, 162 insertions(+), 108 deletions(-)
git pull ssh://gerrit.wikimedia.org:29418/marvin refs/changes/64/396464/1
diff --git a/package.json b/package.json
index ffabbd5..259a75e 100644
--- a/package.json
+++ b/package.json
@@ -104,7 +104,7 @@
"bundlesize": [
{
"path": "dist/public/index.*.js",
- "maxSize": "3.9KB"
+ "maxSize": "4KB"
},
{
"path": "dist/public/runtime.*.js",
diff --git a/src/common/pages/home.tsx b/src/common/pages/home.tsx
index adb4091..24b77c2 100644
--- a/src/common/pages/home.tsx
+++ b/src/common/pages/home.tsx
@@ -12,6 +12,7 @@
import Page from "../components/page/page";
import { PageTitleID } from "../models/page/title";
import Link from "../components/link/link";
+import { QueryParams } from "../router/route";
const testSummaries = [
{ title: "Banana", text: "With landscape image" },
@@ -24,7 +25,7 @@
{ title: "Carrot cake", text: "Encoding redirect (301)" },
{ title: "Cheese_cake", text: "Redirect page (302)" },
{ title: "Nonexistent_title", text: "Missing (404)" },
- { title: "Pizza", query: "foo=bar", text: "Query string" }
+ { title: "Pizza", query: { foo: "bar" }, text: "Query string" }
];
const testPages = [
@@ -42,7 +43,7 @@
text: "Redirect (external) and File page"
},
{ title: "Nonexistent_title", text: "Missing (404)" },
- { title: "Pizza", query: "foo=bar", text: "Query string" }
+ { title: "Pizza", query: { foo: "bar" }, text: "Query string" }
];
export default {
@@ -82,12 +83,14 @@
text
}: {
title: PageTitleID | string;
- query?: string;
+ query?: QueryParams;
revision?: string;
text: string;
}) => (
<li>
- <Link href={wiki.toPath({ title, revision }, query)}>
+ <Link
+ href={wiki.toPath({ path: { title, revision }, query })}
+ >
{text}
</Link>
</li>
@@ -109,11 +112,13 @@
text
}: {
title: PageTitleID | string;
- query?: string;
+ query?: QueryParams;
text: string;
}) => (
<li>
- <Link href={summary.toPath({ title }, query)}>{text}</Link>
+ <Link href={summary.toPath({ path: { title }, query })}>
+ {text}
+ </Link>
</li>
)
)}
diff --git a/src/common/pages/not-found.tsx b/src/common/pages/not-found.tsx
index ce307bb..f79738e 100644
--- a/src/common/pages/not-found.tsx
+++ b/src/common/pages/not-found.tsx
@@ -3,7 +3,7 @@
import Page from "../components/page/page";
export interface Props {
- path: string;
+ pathQuery: string;
}
// Note: visually, this page may have some similarities to the generic
@@ -13,11 +13,11 @@
export default {
status: 404,
- Component({ path }: Props): JSX.Element {
+ Component({ pathQuery }: Props): JSX.Element {
return (
<App>
<Page title="Page not found" subtitle="Error 404">
- <p>Not found: {path}</p>
+ <p>Not found: {pathQuery}</p>
</Page>
</App>
);
diff --git a/src/common/pages/summary.tsx b/src/common/pages/summary.tsx
index 229a620..4b61eb9 100644
--- a/src/common/pages/summary.tsx
+++ b/src/common/pages/summary.tsx
@@ -13,18 +13,20 @@
import { RedirectError } from "../http/fetch";
import { unmarshalPageTitleID } from
"../marshallers/page-base/page-base-unmarshaller"; // eslint-disable-line
max-len
-interface PageParams extends RouteParams {
- /**
- * When used as an input, an (unencoded, not necessarily denormalized)
- * possible PageTitleID; when used as an output of Route.toParams(), an
- * (encoded, not necessarily denormalized) PageTitlePath.
- */
- title: PageTitleID | PageTitlePath | string;
+interface PageParams extends Partial<RouteParams> {
+ path: {
+ /**
+ * When used as an input, an (unencoded, not necessarily denormalized)
+ * possible PageTitleID; when used as an output of Route.toParams(), an
+ * (encoded, not necessarily denormalized) PageTitlePath.
+ */
+ title: PageTitleID | PageTitlePath | string;
+ };
}
// undefined means random input (Route.toPath()) and {} means random output
// (Route.toParams()).
-export type Params = PageParams | { title?: undefined } | undefined;
+export type Params = PageParams | { path?: undefined } | undefined;
export interface Props {
summary: PageSummaryModel;
@@ -35,12 +37,14 @@
init?: RequestInit
): Promise<HttpResponse<PageSummaryModel>> {
return requestSummary(
- params.title === undefined
+ params.path === undefined
? { random: true, init }
- : { titlePath: params.title, init }
+ : { titlePath: params.path.title, init }
).catch(error => {
if (error instanceof RedirectError) {
- const url = summary.toPath({ title: unmarshalPageTitleID(error.url) });
+ const url = summary.toPath({
+ path: { title: unmarshalPageTitleID(error.url) }
+ });
throw new RedirectError(error.status, url);
}
diff --git a/src/common/pages/wiki.tsx b/src/common/pages/wiki.tsx
index e854d12..0a98317 100644
--- a/src/common/pages/wiki.tsx
+++ b/src/common/pages/wiki.tsx
@@ -14,22 +14,21 @@
import { Thumbnail } from "../components/thumbnail/thumbnail";
import { unmarshalPageTitleID } from
"../marshallers/page-base/page-base-unmarshaller"; // eslint-disable-line
max-len
-interface PageParams extends RouteParams {
- /**
- * When used as an input, an (unencoded, not necessarily denormalized)
- * possible PageTitleID; when used as an output of Route.toParams(), an
- * (encoded, not necessarily denormalized) PageTitlePath.
- */
- title: PageTitleID | PageTitlePath | string;
- revision?: string;
+interface PageParams extends Partial<RouteParams> {
+ path: {
+ /**
+ * When used as an input, an (unencoded, not necessarily denormalized)
+ * possible PageTitleID; when used as an output of Route.toParams(), an
+ * (encoded, not necessarily denormalized) PageTitlePath.
+ */
+ title: PageTitleID | PageTitlePath | string;
+ revision?: string;
+ };
}
// undefined means random input (Route.toPath()) and {} means random output
// (Route.toParams()).
-export type Params =
- | PageParams
- | { title?: undefined; revision?: undefined }
- | undefined;
+export type Params = PageParams | { path?: undefined } | undefined;
export interface Props {
page: PageModel;
@@ -40,20 +39,25 @@
init?: RequestInit
): Promise<HttpResponse<PageModel>> {
return requestPage(
- params.title === undefined
+ params.path === undefined
? { random: true, init }
: {
- titlePath: params.title,
+ titlePath: params.path.title,
revision:
- params.revision === undefined
+ params.path.revision === undefined
? undefined
- : parseInt(params.revision, 10),
+ : parseInt(params.path.revision, 10),
init
}
).catch(error => {
if (error instanceof RedirectError) {
const title = unmarshalPageTitleID(error.url);
- const url = wiki.toPath({ title, revision: params.revision });
+ const url = wiki.toPath({
+ path: {
+ title,
+ revision: params.path ? params.path.revision : undefined
+ }
+ });
throw new RedirectError(error.status, url);
}
diff --git a/src/common/router/route.ts b/src/common/router/route.ts
index 7754450..95e52ec 100644
--- a/src/common/router/route.ts
+++ b/src/common/router/route.ts
@@ -3,15 +3,18 @@
import HttpResponse from "../http/http-response";
/**
- * A map of path-to-regexp router path names to matches. The keys must match
- * those specified in RouteConfig.path. This map is passed to Route.toPath()
- * with unencoded keys, constructed by Route.toParams() with encoded keys, and
- * passed to PageModule.getInitialProps() with encoded keys.
+ * A map of path-to-regexp router path names to matches. The path keys must
+ * match those specified in RouteConfig.path. This map is passed to
+ * Route.toPath() with unencoded keys, constructed by Route.toParams() with
+ * encoded keys, and passed to PageModule.getInitialProps() with encoded keys.
+ * Query properties are always considered optional.
*
* Note: only strings are ever returned as outputs of Route.toParams() and all
* inputs are converted to strings in Route.toPath().
*/
-export type RouteParams = { [name: string]: string | undefined };
+export type PathParams = { [name: string]: string | undefined };
+export type QueryParams = { [name: string]: string | undefined };
+export type RouteParams = { path: PathParams; query: QueryParams };
/**
* A file that exposes a Preact UI component and optionally a function to
@@ -68,17 +71,18 @@
export interface Route<Params> extends RouteConfig {
/**
- * Generate a Params object from a given URL path or void if the path does
not
- * match. An empty object is returned for matching NoPropsRoutes.
+ * Generate a Params object from a given URL path and query string or void if
+ * the path does not match. An empty object is returned for matching
+ * NoPropsRoutes.
*/
- toParams(path: string): Params | void; // eslint-disable-line
no-use-before-define
+ toParams(pathQuery: string): Params | void; // eslint-disable-line
no-use-before-define
/** Generates a URL path from Params. */
- toPath(params: Params, query?: string): string;
+ toPath(params: Params): string; // eslint-disable-line no-use-before-define
}
export interface NoParamsRoute extends Route<undefined> {
- toPath(params?: undefined, query?: string): string;
+ toPath(params?: undefined): string; // eslint-disable-line
no-use-before-define
}
/**
@@ -93,21 +97,41 @@
function toParams(
pathRegExp: RegExp,
paramNames: pathToRegExp.Key[],
- path: string
+ pathQuery: string
) {
- const matches = pathRegExp.exec(path);
+ const matches = pathRegExp.exec(pathQuery);
if (matches) {
const [, ...paramValues] = matches;
- const params: RouteParams = {};
+ const params: RouteParams = { path: {}, query: {} };
paramNames.forEach((param, index) => {
- params[param.name] = paramValues[index];
+ params.path[param.name] = paramValues[index];
});
+ // todo: query parsing.
return params;
}
return undefined;
}
-export function newRoute<Params>({ path, page }: RouteConfig): Route<Params> {
+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>({
+ path,
+ page
+}: RouteConfig): Route<Params> {
const paramNames: pathToRegExp.Key[] = [];
const pathRegExp = pathToRegExp(
path,
@@ -115,11 +139,11 @@
// Allow query parameters.
{ endsWith: "?" }
);
- const toPath = pathToRegExp.compile(path);
+ const pathFunction = pathToRegExp.compile(path);
return {
path,
page,
- toParams: path => toParams(pathRegExp, paramNames, path),
- toPath: (path, query) => `${toPath(path)}${query ? `?${query}` : ""}`
+ toParams: pathQuery => toParams(pathRegExp, paramNames, pathQuery),
+ toPath: params => toPath(pathFunction, params)
} as Route<Params>;
}
diff --git a/src/common/router/router.test.ts b/src/common/router/router.test.ts
index 4fe9b65..2e00894 100644
--- a/src/common/router/router.test.ts
+++ b/src/common/router/router.test.ts
@@ -40,7 +40,7 @@
.route("/404")
.then(res => {
assert.deepEqual(res.status, 404);
- assert.deepEqual(res.props.path, "/404");
+ assert.deepEqual(res.props.pathQuery, "/404");
});
});
@@ -85,7 +85,7 @@
.route("/wiki/Nonexistent_title")
.then(rsp => {
assert.deepEqual(rsp.status, 404);
- assert.deepEqual(rsp.props.path, "/wiki/Nonexistent_title");
+ assert.deepEqual(rsp.props.pathQuery, "/wiki/Nonexistent_title");
});
});
diff --git a/src/common/router/router.ts b/src/common/router/router.ts
index 21326c2..b5fc5f4 100644
--- a/src/common/router/router.ts
+++ b/src/common/router/router.ts
@@ -58,8 +58,8 @@
// todo: can we load this page dynamically instead? Many users will never even
// see a 404.
-function respondNotFound(path: string): Promise<RouteResponse<any>> {
- const props: NotFoundProps = { path };
+function respondNotFound(pathQuery: string): Promise<RouteResponse<any>> {
+ const props: NotFoundProps = { pathQuery };
return Promise.resolve({
status: notFoundPage.status,
Component: notFoundPage.Component,
@@ -68,13 +68,16 @@
});
}
-function respondError(path: string, error: Error): Promise<RouteResponse<any>>
{
+function respondError(
+ pathQuery: string,
+ error: Error
+): Promise<RouteResponse<any>> {
// Throw up RedirectErrors so that they can be handled by the server/client
// appropriately
if (error instanceof RedirectError) throw error;
if (error instanceof ClientError && error.status === 404) {
- return respondNotFound(path);
+ return respondNotFound(pathQuery);
}
console.error(`${error.message}\n${error.stack}`); // eslint-disable-line
no-console
@@ -94,16 +97,16 @@
requestPageModule: RequestPageModule<any, any> = requestPageModuleChunk
) {
return {
- route(path: string): Promise<RouteResponse<any>> {
+ route(pathQuery: string): Promise<RouteResponse<any>> {
for (const route of routes) {
- const params = route.toParams(path);
+ const params = route.toParams(pathQuery);
if (params) {
return respond(requestPageModule, route, params).catch(error =>
- respondError(path, error)
+ respondError(pathQuery, error)
);
}
}
- return respondNotFound(path);
+ return respondNotFound(pathQuery);
}
};
}
diff --git a/src/common/router/routes.test.ts b/src/common/router/routes.test.ts
index 44447bc..18d9ac2 100644
--- a/src/common/router/routes.test.ts
+++ b/src/common/router/routes.test.ts
@@ -10,7 +10,7 @@
} from "./routes";
import { Route, RouteParams } from "./route";
-interface TestParams<Params extends RouteParams | undefined> {
+interface TestParams<Params extends Partial<RouteParams> | undefined> {
name: string;
route: Route<Params>;
// The raw path. Only path-to-regexp knows how to construct a path from
params
@@ -21,19 +21,22 @@
params: Params;
}
-function testPathParams<Params extends RouteParams | undefined>({
- name,
- route,
- path,
- params
-}: TestParams<Params>) {
- it(`${name} path and parameter types are in sync`, () => {
- const expected: RouteParams = {};
- Object.keys((params as RouteParams) || {}).forEach(name => {
- const value = params && params[name];
- expected[name] =
- value === undefined ? undefined : encodeURIComponent(value);
- });
+function testPathParams({ name, route, path, params }: TestParams<any>) {
+ it(`${name} path and route parameter types are in sync`, () => {
+ const expected: RouteParams = { path: {}, query: {} };
+ if (params) {
+ Object.keys(params.path || {}).forEach(name => {
+ const value = params.path[name];
+ expected.path[name] =
+ value === undefined ? undefined : encodeURIComponent(value);
+ });
+
+ Object.keys(params.query || {}).forEach(name => {
+ const value = params.query[name];
+ expected.query[name] =
+ value === undefined ? undefined : encodeURIComponent(value);
+ });
+ }
const path = route.toPath(params);
const result = route.toParams(path);
@@ -41,11 +44,18 @@
});
it(`${name} unescaped path matches`, () => {
- const expected: RouteParams = {};
- Object.keys((params as RouteParams) || {}).forEach(name => {
- const value = params && params[name];
- expected[name] = value;
- });
+ const expected: RouteParams = { path: {}, query: {} };
+ if (params) {
+ Object.keys(params.path || {}).forEach(name => {
+ const value = params.path[name];
+ expected.path[name] = value;
+ });
+
+ Object.keys(params.query || {}).forEach(name => {
+ const value = params.query[name];
+ expected.query[name] = value;
+ });
+ }
const result = route.toParams(path);
assert.deepStrictEqual(result, expected);
@@ -63,81 +73,83 @@
name: "wiki (title)",
route: wiki,
path: "/wiki/title",
- params: { title: "title", revision: undefined }
+ params: { path: { title: "title", revision: undefined } }
},
{
name: "wiki (title, revision)",
route: wiki,
path: "/wiki/title/1",
- params: { title: "title", revision: "1" }
+ params: { path: { title: "title", revision: "1" } }
},
{
name: "wiki (title with slash)",
route: wiki,
path: "/wiki/title/text",
- params: { title: "title/text", revision: undefined }
+ params: { path: { title: "title/text", revision: undefined } }
},
{
name: "wiki (title with slash, revision)",
route: wiki,
path: "/wiki/title/text/123456789",
- params: { title: "title/text", revision: "123456789" }
+ params: { path: { title: "title/text", revision: "123456789" } }
},
{
name: "wiki (title with trailing slash)",
route: wiki,
path: "/wiki/title/",
- params: { title: "title", revision: undefined }
+ params: { path: { title: "title", revision: undefined } }
},
{
name: "wiki (title with trailing slash, revision)",
route: wiki,
path: "/wiki/title/123456789/",
- params: { title: "title", revision: "123456789" }
+ params: { path: { title: "title", revision: "123456789" } }
},
{
name: "wiki (title with slash and trailing slash)",
route: wiki,
path: "/wiki/title/text/",
- params: { title: "title/text", revision: undefined }
+ params: { path: { title: "title/text", revision: undefined } }
},
{
name: "wiki (title with slash and trailing slash, revision)",
route: wiki,
path: "/wiki/title/text/123456789/",
- params: { title: "title/text", revision: "123456789" }
+ params: { path: { title: "title/text", revision: "123456789" } }
},
{
name: "wiki (title is a slash)",
route: wiki,
path: "/wiki//",
- params: { title: "/", revision: undefined }
+ params: { path: { title: "/", revision: undefined } }
},
{
name: "wiki (title is a slash, revision)",
route: wiki,
path: "/wiki///123456789/",
- params: { title: "/", revision: "123456789" }
+ params: { path: { title: "/", revision: "123456789" } }
},
{
name: "wiki (title is a question mark)",
route: wiki,
path: "/wiki/%3f",
- params: { title: "%3f", revision: undefined }
+ params: { path: { title: "%3f", revision: undefined } }
},
{
name: "wiki (title is a question mark, revision)",
route: wiki,
path: "/wiki/%3f/123456789/",
- params: { title: "%3f", revision: "123456789" }
+ params: { path: { title: "%3f", revision: "123456789" } }
},
{
name: "wiki (title with every supported character class)",
route: wiki,
path: "/wiki/ %!\"$&'()*,-./0:;=@A\\^_`a~\x80+",
params: {
- title: " %!\"$&'()*,-./0:;=@A\\^_`a~\x80+",
- revision: undefined
+ path: {
+ title: " %!\"$&'()*,-./0:;=@A\\^_`a~\x80+",
+ revision: undefined
+ }
}
},
{
@@ -145,51 +157,53 @@
route: wiki,
path: "/wiki/ %!\"$&'()*,-./0:;=@A\\^_`a~\x80+/123456789",
params: {
- title: " %!\"$&'()*,-./0:;=@A\\^_`a~\x80+",
- revision: "123456789"
+ path: {
+ title: " %!\"$&'()*,-./0:;=@A\\^_`a~\x80+",
+ revision: "123456789"
+ }
}
},
{
name: "summary (title)",
route: summary,
path: "/page/summary/title",
- params: { title: "title" }
+ params: { path: { title: "title" } }
},
{
name: "summary (title with slash)",
route: summary,
path: "/page/summary/title/text",
- params: { title: "title/text" }
+ params: { path: { title: "title/text" } }
},
{
name: "summary (title with trailing slash)",
route: summary,
path: "/page/summary/title/",
- params: { title: "title" }
+ params: { path: { title: "title" } }
},
{
name: "summary (title is a slash)",
route: summary,
path: "/page/summary//",
- params: { title: "/" }
+ params: { path: { title: "/" } }
},
{
name: "summary (title with slash and trailing slash)",
route: summary,
path: "/page/summary/title/text/",
- params: { title: "title/text" }
+ params: { path: { title: "title/text" } }
},
{
name: "summary (title is a question mark)",
route: summary,
path: "/page/summary/%3f",
- params: { title: "%3f" }
+ params: { path: { title: "%3f" } }
},
{
name: "summary (title with every supported character class)",
route: summary,
path: "/page/summary/ %!\"$&'()*,-./0:;=@A\\^_`a~\x80+",
- params: { title: " %!\"$&'()*,-./0:;=@A\\^_`a~\x80+" }
+ params: { path: { title: " %!\"$&'()*,-./0:;=@A\\^_`a~\x80+" } }
},
{
name: "random (wiki)",
--
To view, visit https://gerrit.wikimedia.org/r/396464
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings
Gerrit-MessageType: newchange
Gerrit-Change-Id: I8f07f06354ffdb459e651958cd0da49a07f23b93
Gerrit-PatchSet: 1
Gerrit-Project: marvin
Gerrit-Branch: master
Gerrit-Owner: Niedzielski <[email protected]>
Gerrit-Reviewer: Sniedzielski <[email protected]>
_______________________________________________
MediaWiki-commits mailing list
[email protected]
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits