DEV Community

Dzinx
Dzinx

Posted on

A New Way Of Ordering Guards In Angular

Overview

With the new Angular feature "functional guards and resolvers", it's easier to make sure that your route guards execute in order instead of all at once.

When there are multiple guards in a route configuration, they are normally executed all at the same time. For example, let's say you have the following route configuration:

{
    path: 'crisis-center',
    canActivate: [FirstGuard, SecondGuard],
    ...
}
Enter fullscreen mode Exit fullscreen mode

Even though the guards are in an array, SecondGuard will always execute no matter whether FirstGuard returns true or not. What we would like to achieve instead is to have SecondGuard not fire at all if FirstGuard doesn't allow it.

For a more life-like example, imagine that your first guard makes sure the user is properly logged in, while the second guard already assumes that and proceeds to call an API that works only for logged-in users.

Functional solution

Up to recent times, the best way to achieve guard ordering was to write an extra "parent" guard to call the other guards in order. This meant that you had to create an additional ordering guard class for every configuration of ordered guards, or to write your guard in a more generic way that made use of the router's data object.

Now there's a much simpler way. If you search for "Functional router guards" in the Angular 15 announcement blog post, you'll see a short description of a new Angular feature. What it says is that you can now use functions instead of classes wherever you want to put a route guard.

In the simplest form, it means we can write the following in the route configuration:

   canActivate: [() => isUserLoggedIn()]
Enter fullscreen mode Exit fullscreen mode

If we combine this new feature with the recently-updated inject function, we could replace any guard class as follows:

  canActivate: [FirstGuard]
Enter fullscreen mode Exit fullscreen mode

with:

  canActivate: [(route, state) =>
    inject(FirstGuard).canActivate(route, state)]
Enter fullscreen mode Exit fullscreen mode

While this is more verbose, it gives us a lot of power to, e.g., combine guards.

Ordered synchronous guards

For the first case, imagine that all your guards are synchronous and they immediately return a boolean. If you want to make them execute in order, you could write the following:

const orderedSyncGuards =
  (guards) =>
    (route, state) =>
      guards.every(guard =>
        inject(guard).canActivate(route, state));

const ROUTE = {
  ...
  canActivate: [orderedSyncGuards([FirstGuard, SecondGuard])]
Enter fullscreen mode Exit fullscreen mode

This example is a bit simplistic, and I removed types of orderedSyncGuards to make the code more readable. Let's now go for a full-blown example with asynchronous guards that can also return UrlTrees.

Ordered asynchronous guards

However, before I go there, a short disclaimer: executing multiple async guards in order will make your page load slower because later guards won't even start their requests until earlier guards finish. If you value performance, you are better off rewriting your guards to handle unfavorable conditions instead. The solution below is provided for the sake of completeness.

You can follow the full example on StackBlitz.

This time we'll assume that all guards return observables that emit either a boolean or a UrlTree and then complete.

Because we want to run the guards one after another, we can use concatMap as our main operator. We also want to break out of the function if the value returned by any guard is different than true, and we want to return the last emitted value.

After some fun with TypeScript types, the following is the final outcome:

interface AsyncGuard extends CanActivate {
  canActivate(
    route: ActivatedRouteSnapshot,
    state: RouterStateSnapshot
  ): Observable<boolean | UrlTree>;
}

function orderedAsyncGuards(
  guards: Array<new () => AsyncGuard>
): CanActivateFn {
  return (route, state) => {
    // Instantiate all guards.
    const guardInstances = guards.map(inject) as AsyncGuard[];
    // Convert an array into an observable.
    return from(guardInstances).pipe(
      // For each guard, fire canActivate and wait for it
      // to complete.
      concatMap((guard) => guard.canActivate(route, state)),
      // Don't execute the next guard if the current guard's
      // result is not true.
      takeWhile((value) => value === true, /* inclusive */ true),
      // Return the last guard's result.
      last()
    );
  };
}

const ROUTE = {
  ...
  canActivate: [orderedAsyncGuards([FirstGuard, SecondGuard])]
Enter fullscreen mode Exit fullscreen mode

And that's how you can write a universal sync or async guard ordering, using a single function call!

I will leave writing a universal ordering guard (sync/async, boolean/UrlTree) as an exercise for the reader :) Here's a hint: start with the async solution and inspect the result of guard.canActivate call.

Top comments (3)

Collapse
 
garethrpratt profile image
garethrpratt • Edited

This is neat! I've built upon it so that you can give it an array of functional guards - the key part being running each one in the current injection context

import { Injector, inject, runInInjectionContext } from '@angular/core';
import { CanActivateFn } from '@angular/router';
import { Observable, from, of } from 'rxjs';
import { concatMap, last, takeWhile } from 'rxjs/operators';

export function SequentialGuards(guards: CanActivateFn[]): CanActivateFn {
  return (route, state) => {
    const injectionContext = inject(Injector);
    // Convert an array into an observable.
    return from(guards).pipe(
      // For each guard, fire canActivate and wait for it to complete.
      concatMap((guard) => {
        return runInInjectionContext(injectionContext, () => {
          var guardResult = guard(route, state);
          if (guardResult instanceof Observable) {
            return guardResult;
          } else if (guardResult instanceof Promise) {
            return from(guardResult);
          } else {
            return of(guardResult);
          }
        });
      }),
      // Don't execute the next guard if the current guard's result is not true.
      takeWhile((value) => value === true, true),
      // Return the last guard's result.
      last()
    );
  };
}
Enter fullscreen mode Exit fullscreen mode
Collapse
 
marvinfrede profile image
Marvin Frede • Edited

Thank you! This works for me too. I wrote some jasmine unit tests for it.

// the function
// I renamed `SequentialGuards` to `guardsInOrder`
import { inject, Injector, runInInjectionContext } from '@angular/core';
import { CanActivateFn } from '@angular/router';
import { concatMap, from, last, Observable, of, takeWhile } from 'rxjs';

/** executes given guards in given order */
export function guardsInOrder(...guards: CanActivateFn[]): CanActivateFn {
  return (route, state) => {
    // convert array of guards to one stream.
    const injectionContext = inject(Injector);
    return from(guards).pipe(
      // for each guard, fire canActivate and wait for it to complete.
      concatMap((guard) => {
        return runInInjectionContext(injectionContext, () => {
          const guardResult = guard(route, state);
          // guard can either be sync, async with Promise or async with Observable.
          if (guardResult instanceof Observable) {
            return guardResult;
          } else if (guardResult instanceof Promise) {
            return from(guardResult);
          } else {
            return of(guardResult);
          }
        });
      }),
      // don't execute the next guard if the current guard's result is not true.
      takeWhile((value) => value === true, true),
      // return the last guard's result.
      last()
    );
  };
}
Enter fullscreen mode Exit fullscreen mode
// the unit tests

import { Component, inject } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { CanActivateFn, provideRouter, Router, UrlTree } from '@angular/router';
import { map, Observable, of, tap, timeout } from 'rxjs';

import { guardsInOrder } from './guardsInOrder.helper';

@Component({
  selector: 'app-blank-test-component',
  template: '',
})
export class BlankTestComponent {}

const exampleGuard00 = (start?: () => void, finish?: () => void): CanActivateFn => {
  return (): boolean => (start?.(), finish?.(), true);
};

const exampleGuard10 = (start?: () => void, finish?: () => void): CanActivateFn => {
  return (): Observable<boolean> => {
    start?.();
    return of(true).pipe(
      timeout(10),
      map(() => true),
      tap(() => finish?.())
    );
  };
};

const exampleGuard80 = (start?: () => void, finish?: () => void): CanActivateFn => {
  return (): Promise<boolean> => {
    start?.();
    return new Promise((resolve) => {
      setTimeout(() => {
        resolve(true);
        finish?.();
      }, 80);
    });
  };
};

const exampleGuardError = (start?: () => void, finish?: () => void): CanActivateFn => {
  return (): Observable<UrlTree> => {
    start?.();
    const router = inject(Router);

    return of(true).pipe(
      timeout(20),
      map(() => router.createUrlTree(['not-found'])),
      tap(() => finish?.())
    );
  };
};

describe('guardsInOrder', () => {
  it('should be able to execute a synchronous guard', (done) => {
    setupRouterWithGuardedRoute(guardsInOrder(exampleGuard00()))
      .navigate(['test'])
      .then((succeeded) => (expect(succeeded).toBeTrue(), done()))
      .catch(() => (fail(), done()));
  });

  it('should be able to execute an asynchronous guard returning a promise', (done) => {
    setupRouterWithGuardedRoute(guardsInOrder(exampleGuard80()))
      .navigate(['test'])
      .then((succeeded) => (expect(succeeded).toBeTrue(), done()))
      .catch(() => (fail(), done()));
  });

  it('should be able to execute an asynchronous guard returning an observable', (done) => {
    setupRouterWithGuardedRoute(guardsInOrder(exampleGuard10()))
      .navigate(['test'])
      .then((succeeded) => (expect(succeeded).toBeTrue(), done()))
      .catch(() => (fail(), done()));
  });

  it('should not change the routing target of the guard in case guard succeeds', (done) => {
    const router = setupRouterWithGuardedRoute(guardsInOrder(exampleGuard10()));
    router
      .navigate(['test'])
      .then((succeeded) => {
        expect(succeeded).toBeTrue();
        expect(router.url).toEqual('/test');
        done();
      })
      .catch(() => (fail(), done()));
  });

  it('should not change the routing target of the guard in case guard fails', (done) => {
    const router = setupRouterWithGuardedRoute(guardsInOrder(exampleGuardError()));
    router
      .navigate(['test'])
      .then((succeeded) => {
        expect(succeeded).toBeTrue();
        expect(router.url).toEqual('/not-found');
        done();
      })
      .catch(() => (fail(), done()));
  });

  it('should execute the 100, 0 & the 800 guards in the given order', (done) => {
    const callOrder: string[] = [];

    const router = setupRouterWithGuardedRoute(
      guardsInOrder(
        exampleGuard10(
          () => callOrder.push('1'),
          () => callOrder.push('2')
        ),
        exampleGuard00(
          () => callOrder.push('3'),
          () => callOrder.push('4')
        ),
        exampleGuard80(
          () => callOrder.push('5'),
          () => callOrder.push('6')
        )
      )
    );
    router
      .navigate(['test'])
      .then((succeeded) => {
        expect(succeeded).toBeTrue();
        expect(router.url).toEqual('/test');
        expect(callOrder).toEqual(['1', '2', '3', '4', '5', '6']);
        done();
      })
      .catch(() => (fail(), done()));
  });

  it('should execute the 800, 100 & the 0 guards in the given order', (done) => {
    const callOrder: string[] = [];

    const router = setupRouterWithGuardedRoute(
      guardsInOrder(
        exampleGuard80(
          () => callOrder.push('1'),
          () => callOrder.push('2')
        ),
        exampleGuard10(
          () => callOrder.push('3'),
          () => callOrder.push('4')
        ),
        exampleGuard00(
          () => callOrder.push('5'),
          () => callOrder.push('6')
        )
      )
    );

    router
      .navigate(['test'])
      .then((succeeded) => {
        expect(succeeded).toBeTrue();
        expect(router.url).toEqual('/test');
        expect(callOrder).toEqual(['1', '2', '3', '4', '5', '6']);
        done();
      })
      .catch(() => (fail(), done()));
  });

  it('should stop executing guards when first guard fails', (done) => {
    const callOrder: string[] = [];

    const router = setupRouterWithGuardedRoute(
      guardsInOrder(
        exampleGuard80(
          () => callOrder.push('1'),
          () => callOrder.push('2')
        ),
        exampleGuardError(
          () => callOrder.push('3'),
          () => callOrder.push('4')
        ),
        exampleGuard10(
          () => callOrder.push('5'),
          () => callOrder.push('6')
        )
      )
    );

    router
      .navigate(['test'])
      .then((success) => {
        expect(success).toBeTrue();
        expect(router.url).toEqual('/not-found');
        expect(callOrder).toEqual(['1', '2', '3', '4']);
        done();
      })
      .catch(() => (fail(), done()));
  });
});

const setupRouterWithGuardedRoute = (guard: CanActivateFn) => {
  TestBed.configureTestingModule({
    declarations: [BlankTestComponent],
    providers: [
      provideRouter([
        {
          path: '',
          component: BlankTestComponent,
        },
        {
          path: 'test',
          component: BlankTestComponent,
          canActivate: [guard],
        },
        {
          path: 'not-found',
          component: BlankTestComponent,
        },
      ]),
    ],
  }).compileComponents();

  return TestBed.inject(Router);
};
Enter fullscreen mode Exit fullscreen mode
Collapse
 
michael_bertuzzi_7c9eeb32 profile image
Michael Bertuzzi

this works for me, thanks!

Some comments may only be visible to logged-in visitors. Sign in to view all comments.