Skip to content

Configurable Guards & Resolvers #42171

Closed
Closed

Description

@homj

Feature Request

Relevant Package

This feature request is for @angular/router

Description

I want to use a guard or a resolver across the app in many places, but always with a slightly different configuration. E.g. a guard should redirect to different pages when some request fails or a resolver should load data from different endpoints.

The current solution is to either pass data in the route and let the guard/resolver access this data, or create a subclass of a guard / resolver for each specific configuration. However, there is no type-safety for route data and the data may be overwritten from a parent route when paramsInheritanceStrategy is active. Additionally, this can get really bloated when using several configured guards or resolvers:

// routes.ts
{
    path: 'page-a',
    component: PageAComponent,
    data: {
        authGuard: {
            failureRedirect: '/login'
        }
    },
    canActivate: [ AuthGuard ],
    resolve: {
        foo: FooContentResolver,
        bar: BarContentResolver
    }
},
{
    path: 'page-b',
    component: PageBComponent,
    data: {
        authGuard: {
            failureRedirect: '/signup'
        }
    },
    canActivate: [ AuthGuard ]
}
// resolvers

@Injectable()
abstract class ContentResolver implements Resolve<any> {
    // ...
}

@Injectable()
class FooContentResolver extends ContentResolver {

    readonly options: IContentResolverOptions = {
        url: '/api/foo',
        method: 'get'
    };

    // ...
}

@Injectable()
class BarContentResolver extends ContentResolver {

    readonly options: IContentResolverOptions = {
        url: '/api/bar',
        method: 'get'
    };

    // ...
}

Describe the solution you'd like

To solve this issue, I'm imagining something similar to the forRoot/ ModuleWithProviders approach, that would let you pass options to some injectable (like here, a guard/resolver). So the canActivate, canActivateChild and canLoad attributes of a Route could also accept some kind of InjectableWithProviders object next to the InjectionToken / string.

// routes.ts
{
    path: 'page-a',
    component: PageAComponent,
    canActivate: [ AuthGuard.withOptions({ failureRedirect: '/login' }) ],
    resolve: {
        foo: ContentResolver.load({
            url: '/api/foo',
            method: 'get'
        }),
        bar: ContentResolver.load({
            url: '/api/bar',
            method: 'get'
        })
    }
},
{
    path: 'page-b',
    component: PageBComponent,
    canActivate: [ AuthGuard.withOptions({ failureRedirect: '/signup' }) ]
}
const AUTH_GUARD_OPTIONS = new InjectionToken<IAuthGuardOptions>('auth-guard-options');

@Injectable()
class AuthGuard implements CanActivate {

    static withOptions(options: IAuthGuardOptions): InjectableWithProviders<AuthGuard> {
        return {
            injectable: AuthGuard,
            providers: [
                { provide: AUTH_GUARD_OPTIONS, useValue: options }
            ]
        };
    }

    // ...

}
const CONTENT_RESOLVER_OPTIONS = new InjectionToken<IContentResolverOptions>('content-resolver-options');

@Injectable()
class ContentResolver implements Resolve<any> {

    static load(options: IContentResolverOptions): InjectableWithProviders<ContentResolver> {
        return {
            injectable: ContentResolver,
            providers: [
                { provide: CONTENT_RESOLVER_OPTIONS, useValue: options }
            ]
        };
    }

    // ...

}

Describe alternatives you've considered

An alternative would be to use something similar to MAT_DIALOG_DATA used in @angular/material. We wouldn't need a fully flexible InjectableWithProviders, but every guard / resolver could inject a common token, e.g. GUARD_OPTIONS / RESOVLER_OPTIONS that should be configurable in the route, e.g.:

// routes.ts
{
    path: 'page-a',
    component: PageAComponent,
    canActivate: [
        { guard: AuthGuard, options: { failureRedirect: '/login' } }
    ],
    resolve: {
        foo: {
            resolver: ContentResolver,
            options: {
                url: '/api/foo',
                method: 'get'
            }
        },
        bar: {
            resolver: ContentResolver,
            options: {
                url: '/api/bar',
                method: 'get'
            }
        }
    }
},
{
    path: 'page-b',
    component: PageBComponent,
    canActivate: [
        { guard: AuthGuard, options: { failureRedirect: '/signup' } }
    ]
}

I also tried to dynamically create a subclass in the static method and return it, but I assume this is not optimal for dependency discovery (and is currently failing with an error when using dependencies in the constructor of the guard: Error: This constructor is not compatible with Angular Dependency Injection because its dependency at index 0 of the parameter list is invalid.):

@Injectable()
class AuthGuard implements CanActivate {

    static withOptions(options: IAuthGuardOptions) {

        @Injectable({ providedIn: 'root' })
        class ConfiguredAuthGuard extends AuthGuard {
            _options = options;
        }

        return ConfiguredAuthGuard
    }

    // ...

}

Related Issues

#18247 seems related to this, but suggests some more substantial changes to routes & guards in general (which I would like to see as well!)

Activity

  1. added this to the needsTriage milestone on May 19, 2021
  2. added
    featureLabel used to distinguish feature request from other issues
    on May 19, 2021
  3. modified the milestones: needsTriage, Backlog on May 19, 2021
  4. angular-robot commented on Jun 5, 2021

    @angular-robot
    Contributor

    This feature request is now candidate for our backlog! In the next phase, the community has 60 days to upvote. If the request receives more than 20 upvotes, we'll move it to our consideration list.

    You can find more details about the feature request process in our documentation.

  5. sg-gdarnell commented on Jun 16, 2021

    @sg-gdarnell

    I don't think the object-oriented solution that you mentioned is really that bad, but I suppose I could see it being a pain if you had like 50 of them.

    That said, you can already accomplish this today using an InjectionToken with the factory option. See this StackBlitz for an example: https://stackblitz.com/edit/configurable-resolver?file=src%2Fapp%2Fapp.module.ts

    Last I checked the factory option isn't well-documented, but it's very useful for cases like this.

  6. SchnWalter commented on Jul 2, 2021

    @SchnWalter
    Contributor

    You can use the Route.data to pass configuration to resolvers and guards, it's always available in the snapshot. But there is one problem: you can't use more than one resolver instance, because there's no way to know under which key the InjectionToken is located.

    You can blame resolveNode() for that:

    (key: string) => getResolver(resolve[key], futureARS, futureRSS, moduleInjector)
    .pipe(tap((value: any) => {
    data[key] = value;
    }))),

    This can have a backwards compatible fix in 2 different ways:

    1. By introducing an optional key argument to the resolve method:

      export interface Resolve<T> {
        resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot, key?: string): Observable<T>|Promise<T>|T;
        //                                                                 ^^^ This is new!
      }

      The change required to implement this is minimal: getResolver() needs to receive the key and pass it along to the resolver.

    2. By changing the Route.resolve to accept the current ResolveData (a map of injection tokens) or a single injection token, for which the service will have to return multiple keys to add to the data object (both fooandbar`).

      export interface Route {
        // ...
        /**
        * A map of DI tokens used to look up data resolvers, or a single DI token that returns a map of data. See `Resolve`.
        */
        resolve?: ResolveData | InjectionToken;
        // ...
      }
      

      Here, we just need to figure out a way to distinguish between a plain object for ResolveData and the full spectrum of values allowed the injection token (which is any, see ResolveData). -- and we probably need to overload the Resolve.resolve() method for this "data map" scenario.

  7. atscott commented on Sep 20, 2022

    @atscott
    Contributor

    Closing as resolved by the feature which allows guards as plain functions (0abb67a). You can now accomplish this configurability by having a factory function that accepts configuration and returns a guard/resolver function.

  8. angular-automatic-lock-bot commented on Oct 21, 2022

    @angular-automatic-lock-bot

    This issue has been automatically locked due to inactivity.
    Please file a new issue if you are encountering a similar or related problem.

    Read more about our automatic conversation locking policy.

    This action has been performed automatically by a bot.

  9. locked and limited conversation to collaborators on Oct 21, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area: routerfeatureLabel used to distinguish feature request from other issuesfeature: under considerationFeature request for which voting has completed and the request is now under consideration

Type

No type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions