core-api: split route ref types into a separate modules

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2021-03-07 00:02:06 +01:00
parent 9bc954b9f8
commit d1bb014a78
8 changed files with 216 additions and 169 deletions
+1 -1
View File
@@ -27,7 +27,7 @@ import {
createExternalRouteRef,
createRouteRef,
createSubRouteRef,
} from '../routing/RouteRef';
} from '../routing';
import { generateBoundRoutes, PrivateAppImpl } from './App';
describe('generateBoundRoutes', () => {
@@ -0,0 +1,84 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
RouteRef,
SubRouteRef,
ExternalRouteRef,
routeRefType,
AnyParams,
ParamKeys,
OptionalParams,
} from './types';
export class ExternalRouteRefImpl<
Params extends AnyParams,
Optional extends boolean
> implements ExternalRouteRef<Params, Optional> {
readonly [routeRefType] = 'external';
constructor(
private readonly id: string,
readonly params: ParamKeys<Params>,
readonly optional: Optional,
) {}
toString() {
return `routeRef{type=external,id=${this.id}}`;
}
}
export function createExternalRouteRef<
Params extends { [param in ParamKey]: string },
Optional extends boolean = false,
ParamKey extends string = never
>(options: {
/**
* An identifier for this route, used to identify it in error messages
*/
id: string;
/**
* The parameters that will be provided to the external route reference.
*/
params?: ParamKey[];
/**
* Whether or not this route is optional, defaults to false.
*
* Optional external routes are not required to be bound in the app, and
* if they aren't, `useRouteRef` will return `undefined`.
*/
optional?: Optional;
}): ExternalRouteRef<OptionalParams<Params>, Optional> {
return new ExternalRouteRefImpl(
options.id,
(options.params ?? []) as ParamKeys<OptionalParams<Params>>,
Boolean(options.optional) as Optional,
);
}
export function isExternalRouteRef<
Params extends AnyParams,
Optional extends boolean
>(
routeRef:
| RouteRef<Params>
| SubRouteRef<Params>
| ExternalRouteRef<Params, Optional>,
): routeRef is ExternalRouteRef<Params, Optional> {
return routeRef[routeRefType] === 'external';
}
+6 -161
View File
@@ -21,6 +21,7 @@ import {
routeRefType,
AnyParams,
ParamKeys,
OptionalParams,
} from './types';
import { IconComponent } from '../icons';
@@ -32,20 +33,11 @@ export type RouteRefConfig<Params extends AnyParams> = {
title: string;
};
class RouteRefBase {
constructor(type: string, id: string) {
this.toString = () => `routeRef{type=${type},id=${id}}`;
}
}
export class RouteRefImpl<Params extends AnyParams>
extends RouteRefBase
implements RouteRef<Params> {
readonly [routeRefType] = 'absolute';
constructor(private readonly config: RouteRefConfig<Params>) {
super('absolute', config.title);
}
constructor(private readonly config: RouteRefConfig<Params>) {}
get params(): ParamKeys<Params> {
return this.config.params as any;
@@ -63,11 +55,11 @@ export class RouteRefImpl<Params extends AnyParams>
get title() {
return this.config.title;
}
}
type OptionalParams<
Params extends { [param in string]: string }
> = Params[keyof Params] extends never ? undefined : Params;
toString() {
return `routeRef{type=absolute,id=${this.config.title}}`;
}
}
export function createRouteRef<
// Params is the type that we care about and the one to be embedded in the route ref.
@@ -92,132 +84,6 @@ export function createRouteRef<
});
}
export class SubRouteRefImpl<Params extends AnyParams>
extends RouteRefBase
implements SubRouteRef<Params> {
readonly [routeRefType] = 'sub';
constructor(
id: string,
readonly path: string,
readonly parent: RouteRef,
readonly params: ParamKeys<Params>,
) {
super('sub', id);
}
}
// These utility types help us infer a Param object type from a string path
// For example, `/foo/:bar/:baz` inferred to `{ bar: string, baz: string }`
type ParamPart<S extends string> = S extends `:${infer Param}` ? Param : never;
type ParamNames<S extends string> = S extends `${infer Part}/${infer Rest}`
? ParamPart<Part> | ParamNames<Rest>
: ParamPart<S>;
type PathParams<S extends string> = { [name in ParamNames<S>]: string };
/**
* Merges a param object type with with an optional params type into a params object
*/
type MergeParams<
P1 extends { [param in string]: string },
P2 extends AnyParams
> = (P1[keyof P1] extends never ? {} : P1) & (P2 extends undefined ? {} : P2);
/**
* Creates a SubRouteRef type given the desired parameters and parent route parameters.
* The parameters types are merged together while ensuring that there is no overlap between the two.
*/
type MakeSubRouteRef<
Params extends { [param in string]: string },
ParentParams extends AnyParams
> = keyof Params & keyof ParentParams extends never
? SubRouteRef<OptionalParams<MergeParams<Params, ParentParams>>>
: never;
export function createSubRouteRef<
Path extends string,
ParentParams extends AnyParams = never
>(config: {
id: string;
path: Path;
parent: RouteRef<ParentParams>;
}): MakeSubRouteRef<PathParams<Path>, ParentParams> {
const { id, path, parent } = config;
type Params = PathParams<Path>;
// Collect runtime parameters from the path, e.g. ['bar', 'baz'] from '/foo/:bar/:baz'
const pathParams = path.split(/:([^/]+)/).filter((_, i) => i % 2 === 1);
const params = [...parent.params, ...pathParams];
if (parent.params.some(p => pathParams.includes(p as string))) {
throw new Error(
'SubRouteRef may not have params that overlap with its parent params',
);
}
if (!path.startsWith('/')) {
throw new Error(`SubRouteRef path sub starts with '/', got '${path}'`);
}
// We ensure that the type of the return type is sane here
const subRouteRef = new SubRouteRefImpl(
id,
path,
parent,
params as ParamKeys<MergeParams<Params, ParentParams>>,
) as SubRouteRef<OptionalParams<MergeParams<Params, ParentParams>>>;
// But skip type checking of the return value itself, because the conditional
// type checking of the parent parameter overlap is tricky to express.
return subRouteRef as any;
}
export class ExternalRouteRefImpl<
Params extends AnyParams,
Optional extends boolean
>
extends RouteRefBase
implements ExternalRouteRef<Params, Optional> {
readonly [routeRefType] = 'external';
constructor(
id: string,
readonly params: ParamKeys<Params>,
readonly optional: Optional,
) {
super('external', id);
}
}
export function createExternalRouteRef<
Params extends { [param in ParamKey]: string },
Optional extends boolean = false,
ParamKey extends string = never
>(options: {
/**
* An identifier for this route, used to identify it in error messages
*/
id: string;
/**
* The parameters that will be provided to the external route reference.
*/
params?: ParamKey[];
/**
* Whether or not this route is optional, defaults to false.
*
* Optional external routes are not required to be bound in the app, and
* if they aren't, `useRouteRef` will return `undefined`.
*/
optional?: Optional;
}): ExternalRouteRef<OptionalParams<Params>, Optional> {
return new ExternalRouteRefImpl(
options.id,
(options.params ?? []) as ParamKeys<OptionalParams<Params>>,
Boolean(options.optional) as Optional,
);
}
export function isRouteRef<Params extends AnyParams>(
routeRef:
| RouteRef<Params>
@@ -226,24 +92,3 @@ export function isRouteRef<Params extends AnyParams>(
): routeRef is RouteRef<Params> {
return routeRef[routeRefType] === 'absolute';
}
export function isSubRouteRef<Params extends AnyParams>(
routeRef:
| RouteRef<Params>
| SubRouteRef<Params>
| ExternalRouteRef<Params, any>,
): routeRef is SubRouteRef<Params> {
return routeRef[routeRefType] === 'sub';
}
export function isExternalRouteRef<
Params extends AnyParams,
Optional extends boolean
>(
routeRef:
| RouteRef<Params>
| SubRouteRef<Params>
| ExternalRouteRef<Params, Optional>,
): routeRef is ExternalRouteRef<Params, Optional> {
return routeRef[routeRefType] === 'external';
}
@@ -0,0 +1,114 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
AnyParams,
ExternalRouteRef,
OptionalParams,
ParamKeys,
RouteRef,
routeRefType,
SubRouteRef,
} from './types';
export class SubRouteRefImpl<Params extends AnyParams>
implements SubRouteRef<Params> {
readonly [routeRefType] = 'sub';
constructor(
private readonly id: string,
readonly path: string,
readonly parent: RouteRef,
readonly params: ParamKeys<Params>,
) {}
toString() {
return `routeRef{type=sub,id=${this.id}}`;
}
}
// These utility types help us infer a Param object type from a string path
// For example, `/foo/:bar/:baz` inferred to `{ bar: string, baz: string }`
type ParamPart<S extends string> = S extends `:${infer Param}` ? Param : never;
type ParamNames<S extends string> = S extends `${infer Part}/${infer Rest}`
? ParamPart<Part> | ParamNames<Rest>
: ParamPart<S>;
type PathParams<S extends string> = { [name in ParamNames<S>]: string };
/**
* Merges a param object type with with an optional params type into a params object
*/
type MergeParams<
P1 extends { [param in string]: string },
P2 extends AnyParams
> = (P1[keyof P1] extends never ? {} : P1) & (P2 extends undefined ? {} : P2);
/**
* Creates a SubRouteRef type given the desired parameters and parent route parameters.
* The parameters types are merged together while ensuring that there is no overlap between the two.
*/
type MakeSubRouteRef<
Params extends { [param in string]: string },
ParentParams extends AnyParams
> = keyof Params & keyof ParentParams extends never
? SubRouteRef<OptionalParams<MergeParams<Params, ParentParams>>>
: never;
export function createSubRouteRef<
Path extends string,
ParentParams extends AnyParams = never
>(config: {
id: string;
path: Path;
parent: RouteRef<ParentParams>;
}): MakeSubRouteRef<PathParams<Path>, ParentParams> {
const { id, path, parent } = config;
type Params = PathParams<Path>;
// Collect runtime parameters from the path, e.g. ['bar', 'baz'] from '/foo/:bar/:baz'
const pathParams = path.split(/:([^/]+)/).filter((_, i) => i % 2 === 1);
const params = [...parent.params, ...pathParams];
if (parent.params.some(p => pathParams.includes(p as string))) {
throw new Error(
'SubRouteRef may not have params that overlap with its parent params',
);
}
if (!path.startsWith('/')) {
throw new Error(`SubRouteRef path sub starts with '/', got '${path}'`);
}
// We ensure that the type of the return type is sane here
const subRouteRef = new SubRouteRefImpl(
id,
path,
parent,
params as ParamKeys<MergeParams<Params, ParentParams>>,
) as SubRouteRef<OptionalParams<MergeParams<Params, ParentParams>>>;
// But skip type checking of the return value itself, because the conditional
// type checking of the parent parameter overlap is tricky to express.
return subRouteRef as any;
}
export function isSubRouteRef<Params extends AnyParams>(
routeRef:
| RouteRef<Params>
| SubRouteRef<Params>
| ExternalRouteRef<Params, any>,
): routeRef is SubRouteRef<Params> {
return routeRef[routeRefType] === 'sub';
}
+2 -5
View File
@@ -35,11 +35,8 @@ import {
validateRoutes,
RouteFunc,
} from './hooks';
import {
createRouteRef,
createExternalRouteRef,
RouteRefConfig,
} from './RouteRef';
import { createRouteRef, RouteRefConfig } from './RouteRef';
import { createExternalRouteRef } from './ExternalRouteRef';
import { AnyRouteRef, RouteRef, ExternalRouteRef } from './types';
const mockConfig = (extra?: Partial<RouteRefConfig<{}>>) => ({
+3 -1
View File
@@ -25,7 +25,9 @@ import {
SubRouteRef,
routeRefType,
} from './types';
import { isRouteRef, isSubRouteRef, isExternalRouteRef } from './RouteRef';
import { isRouteRef } from './RouteRef';
import { isSubRouteRef } from './SubRouteRef';
import { isExternalRouteRef } from './ExternalRouteRef';
// The extra TS magic here is to require a single params argument if the RouteRef
// had at least one param defined, but require 0 arguments if there are no params defined.
+3 -1
View File
@@ -22,6 +22,8 @@ export type {
ExternalRouteRef,
} from './types';
export { FlatRoutes } from './FlatRoutes';
export { createRouteRef, createExternalRouteRef } from './RouteRef';
export { createRouteRef } from './RouteRef';
export { createSubRouteRef } from './SubRouteRef';
export { createExternalRouteRef } from './ExternalRouteRef';
export type { RouteRefConfig } from './RouteRef';
export { useRouteRef } from './hooks';
+3
View File
@@ -21,6 +21,9 @@ export type AnyParams = { [param in string]: string } | undefined;
export type ParamKeys<Params extends AnyParams> = keyof Params extends never
? []
: (keyof Params)[];
export type OptionalParams<
Params extends { [param in string]: string }
> = Params[keyof Params] extends never ? undefined : Params;
export const routeRefType: unique symbol = getGlobalSingleton<any>(
'route-ref-type',