Skip to content

Commit

Permalink
feat(router): allow guards and resolvers to be plain functions
Browse files Browse the repository at this point in the history
The current Router APIs require guards/resolvers to be present in the DI tree. This is because we want to treat all guards/resolvers equally and some may require dependencies. This requirement results in quite a lot of boilerplate for guards. Here are two examples:

```
const MY_GUARD = new InjectionToken<any>('my_guard');
…
providers: {provide: MY_GUARD, useValue: () => window.someGlobalState}
…
const route = {path: 'somePath', canActivate: [MY_GUARD]}
```

```
@Injectable({providedIn: 'root'})
export class MyGuardWithDependency {
  constructor(private myDep: MyDependency) {}

  canActivate() {
    return myDep.canActivate();
  }
}
…
const route = {path: 'somePath', canActivate: [MyGuardWithDependency]}
```

Notice that even when we want to write a simple guard that has no dependencies as in the first example, we still have to write either an InjectionToken or an Injectable class.

With this commit router guards and resolvers can be plain old functions.
 For example:

```
const route = {path: 'somePath', component: EditCmp, canDeactivate: [(component: EditCmp) => !component.hasUnsavedChanges]}
```

Additionally, these functions can still use Angular DI with `inject` from `@angular/core`.

```
const route = {path: 'somePath', canActivate: [() => inject(MyDependency).canActivate()]}
```
  • Loading branch information
atscott committed Jul 20, 2022
1 parent d583f85 commit b329766
Show file tree
Hide file tree
Showing 13 changed files with 391 additions and 162 deletions.
29 changes: 22 additions & 7 deletions goldens/public-api/router/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,18 +123,30 @@ export interface CanActivateChild {
canActivateChild(childRoute: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree;
}

// @public (undocumented)
export type CanActivateChildFn = (childRoute: ActivatedRouteSnapshot, state: RouterStateSnapshot) => Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree;

// @public (undocumented)
export type CanActivateFn = (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree;

// @public
export interface CanDeactivate<T> {
// (undocumented)
canDeactivate(component: T, currentRoute: ActivatedRouteSnapshot, currentState: RouterStateSnapshot, nextState?: RouterStateSnapshot): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree;
}

// @public (undocumented)
export type CanDeactivateFn<T> = (component: T, currentRoute: ActivatedRouteSnapshot, currentState: RouterStateSnapshot, nextState?: RouterStateSnapshot) => Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree;

// @public
export interface CanLoad {
// (undocumented)
canLoad(route: Route, segments: UrlSegment[]): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree;
}

// @public (undocumented)
export type CanLoadFn = (route: Route, segments: UrlSegment[]) => Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree;

// @public
export interface CanMatch {
// (undocumented)
Expand Down Expand Up @@ -500,7 +512,7 @@ export interface Resolve<T> {

// @public
export type ResolveData = {
[key: string | symbol]: any;
[key: string | symbol]: any | ResolveFn<unknown>;
};

// @public
Expand All @@ -520,6 +532,9 @@ export class ResolveEnd extends RouterEvent {
urlAfterRedirects: string;
}

// @public
export type ResolveFn<T> = (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) => Observable<T> | Promise<T> | T;

// @public
export class ResolveStart extends RouterEvent {
constructor(
Expand All @@ -539,11 +554,11 @@ export class ResolveStart extends RouterEvent {

// @public
export interface Route {
canActivate?: any[];
canActivateChild?: any[];
canDeactivate?: any[];
canLoad?: any[];
canMatch?: Array<Type<CanMatch> | InjectionToken<CanMatchFn>>;
canActivate?: Array<CanActivateFn | any>;
canActivateChild?: Array<CanActivateChildFn | any>;
canDeactivate?: Array<CanDeactivateFn<any> | any>;
canLoad?: Array<CanLoadFn | any>;
canMatch?: Array<Type<CanMatch> | InjectionToken<CanMatchFn> | CanMatchFn>;
children?: Routes;
component?: Type<any>;
data?: Data;
Expand All @@ -557,7 +572,7 @@ export interface Route {
redirectTo?: string;
resolve?: ResolveData;
runGuardsAndResolvers?: RunGuardsAndResolvers;
title?: string | Type<Resolve<string>>;
title?: string | Type<Resolve<string>> | ResolveFn<string>;
}

// @public
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/core_render3_private_export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ export {
} from './render3/jit/pipe';
export {
isStandalone as ɵisStandalone,
isInjectable as ɵisInjectable,
} from './render3/jit/module';
export { Profiler as ɵProfiler, ProfilerEvent as ɵProfilerEvent } from './render3/profiler';
export {
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/render3/jit/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/

import {getCompilerFacade, JitCompilerUsage, R3InjectorMetadataFacade} from '../../compiler/compiler_facade';
import {ProviderToken} from '../../di';
import {resolveForwardRef} from '../../di/forward_ref';
import {NG_INJ_DEF} from '../../di/interface/defs';
import {ModuleWithProviders} from '../../di/interface/provider';
Expand Down Expand Up @@ -202,6 +203,10 @@ export function isStandalone<T>(type: Type<T>) {
return def !== null ? def.standalone : false;
}

export function isInjectable(token: ProviderToken<unknown>) {
return (token as any).ɵprov !== undefined;
}

export function generateStandaloneInDeclarationsError(type: Type<any>, location: string) {
const prefix = `Unexpected "${stringifyForError(type)}" found in the "declarations" array of the`;
const suffix = `"${stringifyForError(type)}" is marked as standalone and can't be declared ` +
Expand Down
2 changes: 1 addition & 1 deletion packages/router/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export {RouterLink, RouterLinkWithHref} from './directives/router_link';
export {RouterLinkActive} from './directives/router_link_active';
export {RouterOutlet, RouterOutletContract} from './directives/router_outlet';
export {ActivationEnd, ActivationStart, ChildActivationEnd, ChildActivationStart, Event, EventType, GuardsCheckEnd, GuardsCheckStart, NavigationCancel, NavigationCancellationCode as NavigationCancellationCode, NavigationEnd, NavigationError, NavigationStart, ResolveEnd, ResolveStart, RouteConfigLoadEnd, RouteConfigLoadStart, RouterEvent, RoutesRecognized, Scroll} from './events';
export {CanActivate, CanActivateChild, CanDeactivate, CanLoad, CanMatch, CanMatchFn, Data, LoadChildren, LoadChildrenCallback, NavigationBehaviorOptions, QueryParamsHandling, Resolve, ResolveData, Route, Routes, RunGuardsAndResolvers, UrlMatcher, UrlMatchResult} from './models';
export {CanActivate, CanActivateChild, CanActivateChildFn, CanActivateFn, CanDeactivate, CanDeactivateFn, CanLoad, CanLoadFn, CanMatch, CanMatchFn, Data, LoadChildren, LoadChildrenCallback, NavigationBehaviorOptions, QueryParamsHandling, Resolve, ResolveData, ResolveFn, Route, Routes, RunGuardsAndResolvers, UrlMatcher, UrlMatchResult} from './models';
export {DefaultTitleStrategy, TitleStrategy} from './page_title_strategy';
export {BaseRouteReuseStrategy, DetachedRouteHandle, RouteReuseStrategy} from './route_reuse_strategy';
export {Navigation, NavigationExtras, Router, UrlCreationOptions} from './router';
Expand Down
25 changes: 17 additions & 8 deletions packages/router/src/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/

import {EnvironmentInjector, ImportedNgModuleProviders, InjectionToken, NgModuleFactory, Provider, Type} from '@angular/core';
import {EnvironmentInjector, ImportedNgModuleProviders, InjectionToken, NgModuleFactory, Provider, ProviderToken, Type} from '@angular/core';
import {Observable} from 'rxjs';

import {ActivatedRouteSnapshot, RouterStateSnapshot} from './router_state';
Expand Down Expand Up @@ -85,7 +85,7 @@ export type Data = {
* @publicApi
*/
export type ResolveData = {
[key: string|symbol]: any
[key: string|symbol]: any|ResolveFn<unknown>
};

/**
Expand Down Expand Up @@ -395,7 +395,7 @@ export interface Route {
*
* @see `PageTitleStrategy`
*/
title?: string|Type<Resolve<string>>;
title?: string|Type<Resolve<string>>|ResolveFn<string>;

/**
* The path to match against. Cannot be used together with a custom `matcher` function.
Expand Down Expand Up @@ -464,32 +464,32 @@ export interface Route {
* handlers, in order to determine if the current user is allowed to
* activate the component. By default, any user can activate.
*/
canActivate?: any[];
canActivate?: Array<CanActivateFn|any>;
/**
* An array of DI tokens used to look up `CanMatch()`
* handlers, in order to determine if the current user is allowed to
* match the `Route`. By default, any route can match.
*/
canMatch?: Array<Type<CanMatch>|InjectionToken<CanMatchFn>>;
canMatch?: Array<Type<CanMatch>|InjectionToken<CanMatchFn>|CanMatchFn>;
/**
* An array of DI tokens used to look up `CanActivateChild()` handlers,
* in order to determine if the current user is allowed to activate
* a child of the component. By default, any user can activate a child.
*/
canActivateChild?: any[];
canActivateChild?: Array<CanActivateChildFn|any>;
/**
* An array of DI tokens used to look up `CanDeactivate()`
* handlers, in order to determine if the current user is allowed to
* deactivate the component. By default, any user can deactivate.
*
*/
canDeactivate?: any[];
canDeactivate?: Array<CanDeactivateFn<any>|any>;
/**
* An array of DI tokens used to look up `CanLoad()`
* handlers, in order to determine if the current user is allowed to
* load the component. By default, any user can load.
*/
canLoad?: any[];
canLoad?: Array<CanLoadFn|any>;
/**
* Additional developer-defined data provided to the component via
* `ActivatedRoute`. By default, no additional data is passed.
Expand Down Expand Up @@ -1055,6 +1055,15 @@ export interface Resolve<T> {
resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<T>|Promise<T>|T;
}

/**
* Function type definition for a data provider.
*
* @see `Route#resolve`.
* @publicApi
*/
export type ResolveFn<T> = (route: ActivatedRouteSnapshot, state: RouterStateSnapshot) =>
Observable<T>|Promise<T>|T;

/**
* @description
*
Expand Down
70 changes: 39 additions & 31 deletions packages/router/src/operators/check_guards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,37 +6,36 @@
* found in the LICENSE file at https://angular.io/license
*/

import {EnvironmentInjector, Injector} from '@angular/core';
import {EnvironmentInjector} from '@angular/core';
import {concat, defer, from, MonoTypeOperatorFunction, Observable, of, OperatorFunction, pipe} from 'rxjs';
import {concatMap, first, map, mergeMap, tap} from 'rxjs/operators';

import {ActivationStart, ChildActivationStart, Event} from '../events';
import {CanLoad, CanLoadFn, CanMatch, CanMatchFn, Route} from '../models';
import {Route} from '../models';
import {redirectingNavigationError} from '../navigation_canceling_error';
import {NavigationTransition} from '../router';
import {ActivatedRouteSnapshot, RouterStateSnapshot} from '../router_state';
import {isUrlTree, UrlSegment, UrlSerializer, UrlTree} from '../url_tree';
import {wrapIntoObservable} from '../utils/collection';
import {CanActivate, CanDeactivate, getCanActivateChild, getToken} from '../utils/preactivation';
import {getClosestRouteInjector} from '../utils/config';
import {CanActivate, CanDeactivate, getCanActivateChild, getTokenOrFunctionIdentity} from '../utils/preactivation';
import {isBoolean, isCanActivate, isCanActivateChild, isCanDeactivate, isCanLoad, isCanMatch} from '../utils/type_guards';

import {prioritizedGuardValue} from './prioritized_guard_value';

export function checkGuards(moduleInjector: Injector, forwardEvent?: (evt: Event) => void):
export function checkGuards(injector: EnvironmentInjector, forwardEvent?: (evt: Event) => void):
MonoTypeOperatorFunction<NavigationTransition> {
return mergeMap(t => {
const {targetSnapshot, currentSnapshot, guards: {canActivateChecks, canDeactivateChecks}} = t;
if (canDeactivateChecks.length === 0 && canActivateChecks.length === 0) {
return of({...t, guardsResult: true});
}

return runCanDeactivateChecks(
canDeactivateChecks, targetSnapshot!, currentSnapshot, moduleInjector)
return runCanDeactivateChecks(canDeactivateChecks, targetSnapshot!, currentSnapshot, injector)
.pipe(
mergeMap(canDeactivate => {
return canDeactivate && isBoolean(canDeactivate) ?
runCanActivateChecks(
targetSnapshot!, canActivateChecks, moduleInjector, forwardEvent) :
runCanActivateChecks(targetSnapshot!, canActivateChecks, injector, forwardEvent) :
of(canDeactivate);
}),
map(guardsResult => ({...t, guardsResult})));
Expand All @@ -45,26 +44,25 @@ export function checkGuards(moduleInjector: Injector, forwardEvent?: (evt: Event

function runCanDeactivateChecks(
checks: CanDeactivate[], futureRSS: RouterStateSnapshot, currRSS: RouterStateSnapshot,
moduleInjector: Injector) {
injector: EnvironmentInjector) {
return from(checks).pipe(
mergeMap(
check =>
runCanDeactivate(check.component, check.route, currRSS, futureRSS, moduleInjector)),
check => runCanDeactivate(check.component, check.route, currRSS, futureRSS, injector)),
first(result => {
return result !== true;
}, true as boolean | UrlTree));
}

function runCanActivateChecks(
futureSnapshot: RouterStateSnapshot, checks: CanActivate[], moduleInjector: Injector,
futureSnapshot: RouterStateSnapshot, checks: CanActivate[], injector: EnvironmentInjector,
forwardEvent?: (evt: Event) => void) {
return from(checks).pipe(
concatMap((check: CanActivate) => {
return concat(
fireChildActivationStart(check.route.parent, forwardEvent),
fireActivationStart(check.route, forwardEvent),
runCanActivateChild(futureSnapshot, check.path, moduleInjector),
runCanActivate(futureSnapshot, check.route, moduleInjector));
runCanActivateChild(futureSnapshot, check.path, injector),
runCanActivate(futureSnapshot, check.route, injector));
}),
first(result => {
return result !== true;
Expand Down Expand Up @@ -107,15 +105,17 @@ function fireChildActivationStart(

function runCanActivate(
futureRSS: RouterStateSnapshot, futureARS: ActivatedRouteSnapshot,
moduleInjector: Injector): Observable<boolean|UrlTree> {
injector: EnvironmentInjector): Observable<boolean|UrlTree> {
const canActivate = futureARS.routeConfig ? futureARS.routeConfig.canActivate : null;
if (!canActivate || canActivate.length === 0) return of(true);

const canActivateObservables = canActivate.map((c: any) => {
return defer(() => {
const guard = getToken(c, futureARS, moduleInjector);
const guardVal = isCanActivate(guard) ? guard.canActivate(futureARS, futureRSS) :
guard(futureARS, futureRSS);
const closestInjector = getClosestRouteInjector(futureARS) ?? injector;
const guard = getTokenOrFunctionIdentity<any>(c, closestInjector);
const guardVal = isCanActivate(guard) ?
guard.canActivate(futureARS, futureRSS) :
closestInjector.runInContext<boolean|UrlTree>(() => guard(futureARS, futureRSS));
return wrapIntoObservable(guardVal).pipe(first());
});
});
Expand All @@ -124,7 +124,7 @@ function runCanActivate(

function runCanActivateChild(
futureRSS: RouterStateSnapshot, path: ActivatedRouteSnapshot[],
moduleInjector: Injector): Observable<boolean|UrlTree> {
injector: EnvironmentInjector): Observable<boolean|UrlTree> {
const futureARS = path[path.length - 1];

const canActivateChildGuards = path.slice(0, path.length - 1)
Expand All @@ -135,9 +135,11 @@ function runCanActivateChild(
const canActivateChildGuardsMapped = canActivateChildGuards.map((d: any) => {
return defer(() => {
const guardsMapped = d.guards.map((c: any) => {
const guard = getToken(c, d.node, moduleInjector);
const guardVal = isCanActivateChild(guard) ? guard.canActivateChild(futureARS, futureRSS) :
guard(futureARS, futureRSS);
const closestInjector = getClosestRouteInjector(d.node) ?? injector;
const guard = getTokenOrFunctionIdentity<any>(c, closestInjector);
const guardVal = isCanActivateChild(guard) ?
guard.canActivateChild(futureARS, futureRSS) :
closestInjector.runInContext(() => guard(futureARS, futureRSS));
return wrapIntoObservable(guardVal).pipe(first());
});
return of(guardsMapped).pipe(prioritizedGuardValue());
Expand All @@ -148,14 +150,16 @@ function runCanActivateChild(

function runCanDeactivate(
component: Object|null, currARS: ActivatedRouteSnapshot, currRSS: RouterStateSnapshot,
futureRSS: RouterStateSnapshot, moduleInjector: Injector): Observable<boolean|UrlTree> {
futureRSS: RouterStateSnapshot, injector: EnvironmentInjector): Observable<boolean|UrlTree> {
const canDeactivate = currARS && currARS.routeConfig ? currARS.routeConfig.canDeactivate : null;
if (!canDeactivate || canDeactivate.length === 0) return of(true);
const canDeactivateObservables = canDeactivate.map((c: any) => {
const guard = getToken(c, currARS, moduleInjector);
const closestInjector = getClosestRouteInjector(currARS) ?? injector;
const guard = getTokenOrFunctionIdentity<any>(c, closestInjector);
const guardVal = isCanDeactivate(guard) ?
guard.canDeactivate(component!, currARS, currRSS, futureRSS) :
guard(component, currARS, currRSS, futureRSS);
guard.canDeactivate(component, currARS, currRSS, futureRSS) :
closestInjector.runInContext<boolean|UrlTree>(
() => guard(component, currARS, currRSS, futureRSS));
return wrapIntoObservable(guardVal).pipe(first());
});
return of(canDeactivateObservables).pipe(prioritizedGuardValue());
Expand All @@ -170,8 +174,10 @@ export function runCanLoadGuards(
}

const canLoadObservables = canLoad.map((injectionToken: any) => {
const guard = injector.get<CanLoad|CanLoadFn>(injectionToken);
const guardVal = isCanLoad(guard) ? guard.canLoad(route, segments) : guard(route, segments);
const guard = getTokenOrFunctionIdentity<any>(injectionToken, injector);
const guardVal = isCanLoad(guard) ?
guard.canLoad(route, segments) :
injector.runInContext<boolean|UrlTree>(() => guard(route, segments));
return wrapIntoObservable(guardVal);
});

Expand All @@ -195,14 +201,16 @@ function redirectIfUrlTree(urlSerializer: UrlSerializer):
}

export function runCanMatchGuards(
injector: Injector, route: Route, segments: UrlSegment[],
injector: EnvironmentInjector, route: Route, segments: UrlSegment[],
urlSerializer: UrlSerializer): Observable<boolean> {
const canMatch = route.canMatch;
if (!canMatch || canMatch.length === 0) return of(true);

const canMatchObservables = canMatch.map(injectionToken => {
const guard = injector.get<CanMatch|CanMatchFn>(injectionToken);
const guardVal = isCanMatch(guard) ? guard.canMatch(route, segments) : guard(route, segments);
const guard = getTokenOrFunctionIdentity(injectionToken, injector);
const guardVal = isCanMatch(guard) ?
guard.canMatch(route, segments) :
injector.runInContext<boolean|UrlTree>(() => guard(route, segments));
return wrapIntoObservable(guardVal);
});

Expand Down

0 comments on commit b329766

Please sign in to comment.