Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add option to OrGuard to throw the last or custom error #44

Merged
merged 3 commits into from
Dec 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading