Skip to content

Commit

Permalink
feat: add option to OrGuard to throw the last or custom error (#44)
Browse files Browse the repository at this point in the history
  • Loading branch information
jmcdo29 authored Dec 10, 2024
2 parents eca3c46 + 458f98f commit 9e49b0b
Show file tree
Hide file tree
Showing 5 changed files with 128 additions and 13 deletions.
6 changes: 6 additions & 0 deletions .changeset/pink-socks-burn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@nest-lab/or-guard': minor
---

Allow for the `OrGuard` to handle throwing the last error or a custom error when
it fails to pass one of the guards.
8 changes: 8 additions & 0 deletions packages/or-guard/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,21 @@ OrGuard(guards: Array<Type<CanActivate> | InjectionToken>, orGuardOptions?: OrGu
```ts
interface OrGuardOptions {
throwOnFirstError?: boolean;
throwLastError?: boolean;
throwError?: object | ((errors: unknown[]) => unknown);
}
```

- `throwOnFirstError`: a boolean to tell the `OrGuard` whether to throw if an
error is encountered or if the error should be considered a `return false`.
The default value is `false`. If this is set to `true`, the **first** error
encountered will lead to the same error being thrown.
- `throwLastError`: a boolean to tell the `OrGuard` if the last error should be
handled with `return false` or just thrown. The default value is `false`. If
this is set to `true`, the **last** error encountered will lead to the same
error being thrown.
- `throwError`: provide a custom error to throw if all guards fail or provide a function
to receive all encountered errors and return a custom error to throw.

> **Note**: guards are ran in a non-deterministic order. All guard returns are
> transformed into Observables and ran concurrently to ensure the fastest
Expand Down
45 changes: 33 additions & 12 deletions packages/or-guard/src/lib/or.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,22 @@ import {
} from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import {
concatMap,
defer,
from,
map,
Observable,
of,
OperatorFunction,
throwError,
pipe, tap
} from 'rxjs';
import { catchError, last, mergeMap, takeWhile } from 'rxjs/operators';

interface OrGuardOptions {
throwOnFirstError?: boolean;
throwLastError?: boolean;
throwError?: object | ((errors: unknown[]) => unknown)
}

export function OrGuard(
Expand All @@ -35,12 +40,25 @@ export function OrGuard(
const canActivateReturns: Array<Observable<boolean>> = this.guards.map(
(guard) => this.deferGuard(guard, context)
);
const errors: unknown[] = [];
return from(canActivateReturns).pipe(
mergeMap((obs) => {
return obs.pipe(this.handleError());
}),
takeWhile((val) => val === false, true),
last()
mergeMap((obs) => obs.pipe(this.handleError())),
tap(({ error }) => errors.push(error)),
takeWhile(({ result }) => result === false, true),
last(),
concatMap(({ result }) => {
if (result === false) {
if (orGuardOptions?.throwLastError) {
return throwError(() => errors.at(-1))
}

if (orGuardOptions?.throwError) {
return throwError(() => typeof orGuardOptions.throwError === 'function' ? orGuardOptions.throwError(errors) : orGuardOptions.throwError)
}
}

return of(result);
})
);
}

Expand All @@ -60,13 +78,16 @@ export function OrGuard(
});
}

private handleError(): OperatorFunction<boolean, boolean> {
return catchError((err) => {
if (orGuardOptions?.throwOnFirstError) {
return throwError(() => err);
}
return of(false);
});
private handleError(): OperatorFunction<boolean, { result: boolean, error?: unknown }> {
return pipe(
catchError((error) => {
if (orGuardOptions?.throwOnFirstError) {
return throwError(() => error);
}
return of({ result: false, error });
}),
map((result) => typeof result === 'boolean' ? { result } : result)
);
}

private guardIsPromise(
Expand Down
20 changes: 19 additions & 1 deletion packages/or-guard/test/app.controller.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { Controller, Get, UnauthorizedException, UseGuards } from '@nestjs/common';

import { AndGuard, OrGuard } from '../src';
import { ObsGuard } from './obs.guard';
Expand Down Expand Up @@ -29,6 +29,24 @@ export class AppController {
return this.message;
}

@UseGuards(OrGuard([SyncGuard, ThrowGuard], { throwLastError: true }))
@Get('throw-last')
getThrowGuardThrowLast() {
return this.message;
}

@UseGuards(OrGuard([ThrowGuard, ThrowGuard], { throwError: new UnauthorizedException('Should provide either "x-api-key" header or query') }))
@Get('throw-custom')
getThrowGuardThrowCustom() {
return this.message;
}

@UseGuards(OrGuard([ThrowGuard, ThrowGuard], { throwError: (errors) => new UnauthorizedException((errors as { message?: string }[]).filter(error => error.message).join(', ')) }))
@Get('throw-custom-narrow')
getThrowGuardThrowCustomNarrow() {
return this.message;
}

@UseGuards(OrGuard(['SyncAndProm', ObsGuard]))
@Get('logical-and')
getLogicalAnd() {
Expand Down
62 changes: 62 additions & 0 deletions packages/or-guard/test/or.guard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,68 @@ describe('OrGuard and AndGuard Integration Test', () => {
});
});
});
describe('throw-last', () => {
/**
* OrGuard([SyncGuard, ThrowGuard], { throwLastError: true })
*
* | Sync | Throw | Final |
* | - | - | - |
* | true | UnauthorizedException | true |
* | false | UnauthorizedException | UnauthorizedException |
*/
it('should throw the last error', async () => {
return supertest(app.getHttpServer())
.get('/throw-last')
.expect(sync ? 200 : 401)
.expect(({ body }) => {
if (!sync) {
expect(body).toEqual(
expect.objectContaining({ message: 'ThrowGuard' })
);
}
});
});
});
describe('throw-custom', () => {
/**
* OrGuard([ThrowGuard, ThrowGuard], { throwError: new UnauthorizedException('Should provide either "x-api-key" header or query') })
*
* | Throw | Throw | Final |
* | - | - | - |
* | UnauthorizedException | UnauthorizedException | object |
* | UnauthorizedException | UnauthorizedException | object |
*/
it('should throw the custom error', async () => {
return supertest(app.getHttpServer())
.get('/throw-custom')
.expect(401)
.expect(({ body }) => {
expect(body).toEqual(
expect.objectContaining({ message: 'Should provide either "x-api-key" header or query' })
);
});
});
});
describe('throw-custom-narrow', () => {
/**
* OrGuard([ThrowGuard, ThrowGuard], { throwError: (errors) => new UnauthorizedException((errors as { message?: string }[]).filter(error => error.message).join(', ')) })
*
* | Throw | Throw | Final |
* | - | - | - |
* | UnauthorizedException | UnauthorizedException | UnauthorizedException |
* | UnauthorizedException | UnauthorizedException | unknown |
*/
it('should throw the custom error', async () => {
return supertest(app.getHttpServer())
.get('/throw-custom-narrow')
.expect(401)
.expect(({ body }) => {
expect(body).toEqual(
expect.objectContaining({ message: 'UnauthorizedException: ThrowGuard, UnauthorizedException: ThrowGuard' })
);
});
});
});
});
}
);
Expand Down

0 comments on commit 9e49b0b

Please sign in to comment.