Skip to content

feat: implement guard support (#24) #80

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

Merged
merged 2 commits into from
Apr 15, 2025
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
5 changes: 5 additions & 0 deletions .changeset/chubby-impalas-invite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@unioc/adapter-nestjs": patch
---

feat: implement guard support (#24)
4 changes: 2 additions & 2 deletions packages/adapter/adapter-nestjs/src/arguments-host-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import type { HttpArgumentsHost, RpcArgumentsHost, WsArgumentsHost } from '@nest

export class ArgumentsHostBuilder implements ArgumentsHost {
constructor(
private readonly _args: readonly unknown[],
private readonly _type: ContextType,
protected readonly _args: readonly unknown[],
protected readonly _type: ContextType,
) {}

private _httpArgumentsHost: HttpArgumentsHost = {} as any
Expand Down
22 changes: 22 additions & 0 deletions packages/adapter/adapter-nestjs/src/execution-context-builder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { ContextType, ExecutionContext, Type } from '@nestjs/common'
import type { IClass } from '@unioc/shared'
import { ArgumentsHostBuilder } from './arguments-host-builder'

export class ExecutionContextBuilder extends ArgumentsHostBuilder implements ExecutionContext {
constructor(
protected readonly _args: readonly unknown[],
protected readonly _type: ContextType,
protected readonly _target: IClass,
protected readonly _handler: (...args: unknown[]) => unknown,
) {
super(_args, _type)
}

getClass<T = any>(): Type<T> {
return this._target
}

getHandler(): (...args: unknown[]) => unknown {
return this._handler
}
}
72 changes: 72 additions & 0 deletions packages/adapter/adapter-nestjs/src/restful/ending-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import type { IResult } from '@unioc/shared'
import type { IRestfulConnect } from '@unioc/web'

export class EndingHandler {
async sendConnectEndingResponse(ctx: IRestfulConnect.WebContext, result: IResult): Promise<void> {
if (ctx.response.writableEnded || ctx.response.writableFinished)
return

if ('send' in ctx.response && typeof ctx.response.send === 'function' && 'status' in ctx.response && typeof ctx.response.status === 'function') {
if (result.type === 'result') {
ctx.response.send(result.value)
}
else {
ctx.response.status(500).send({
statusCode: 500,
message: 'Internal Server Error',
error: await this._toReadableError(result.error),
})
}
return
}

if (result.type === 'result') {
ctx.response.end(await this._toSendableString(result.value))
}
else {
ctx.response.statusCode = 500
ctx.response.end(
await this._toSendableString({
statusCode: 500,
message: 'Internal Server Error',
error: await this._toReadableError(result.error),
}),
)
}
}

private async _stringifyAsync(data: unknown): Promise<string> {
return JSON.stringify(data)
}

private async _toSendableString(data: unknown): Promise<string> {
if (typeof data === 'string')
return data
if (typeof data === 'object' && data !== null) {
return await this._stringifyAsync(data)
.catch(async error => this._stringifyAsync({
statusCode: 500,
message: 'Internal Server Error',
error: await this._toReadableError(error),
}))
.catch(async error => this._stringifyAsync({
statusCode: 500,
message: 'Internal Server Error',
error: await this._toReadableError(error),
}))
Comment on lines +46 to +56
Copy link
Preview

Copilot AI Apr 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Chaining two consecutive .catch calls on _stringifyAsync may hide errors rather than handling them explicitly. Consider refactoring this logic with a try-catch block for clearer and more reliable error handling.

Suggested change
return await this._stringifyAsync(data)
.catch(async error => this._stringifyAsync({
statusCode: 500,
message: 'Internal Server Error',
error: await this._toReadableError(error),
}))
.catch(async error => this._stringifyAsync({
statusCode: 500,
message: 'Internal Server Error',
error: await this._toReadableError(error),
}))
try {
return await this._stringifyAsync(data)
} catch (error) {
try {
return await this._stringifyAsync({
statusCode: 500,
message: 'Internal Server Error',
error: await this._toReadableError(error),
})
} catch (nestedError) {
return await this._stringifyAsync({
statusCode: 500,
message: 'Internal Server Error',
error: await this._toReadableError(nestedError),
})
}
}

Copilot uses AI. Check for mistakes.

}
return String(data)
}

private async _toReadableError(error: unknown): Promise<Record<string, unknown>> {
if (error && error instanceof Error) {
const result: Record<string, unknown> = {}
if (error.name !== undefined)
result.name = error.name
for (const key of Reflect.ownKeys(error))
result[key as string] = error[key as keyof Error]
return result
}
return error as Record<string, unknown>
}
}
115 changes: 47 additions & 68 deletions packages/adapter/adapter-nestjs/src/restful/restful-handler.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import type { IMethodExecuteOptions, IRestfulConnect, IRestMethodOperator } from '@unioc/web'
import type { INestJSMethodParamMetadata, NestJSMethodWrapper } from './method-wrapper'
import { UnauthorizedException } from '@nestjs/common'
import { ExecutionContextBuilder } from '../execution-context-builder'
import { EndingHandler } from './ending-handler'
import { NestJSMethodOperator } from './method-operator'

export class NestJSRestfulHandler implements IRestfulConnect.Handler {
Expand Down Expand Up @@ -55,7 +58,7 @@ export class NestJSRestfulHandler implements IRestfulConnect.Handler {
}
}

private async _buildParams(methodWrapper: NestJSMethodWrapper, ctx: IRestfulConnect.WebContext): Promise<unknown[]> {
protected async buildParams(methodWrapper: NestJSMethodWrapper, ctx: IRestfulConnect.WebContext): Promise<unknown[]> {
const params: unknown[] = []
const metadata = methodWrapper.getMethodParamMetadata()
const paramTypes = methodWrapper.getParamTypes()
Expand Down Expand Up @@ -109,83 +112,59 @@ export class NestJSRestfulHandler implements IRestfulConnect.Handler {
&& typeof options.adapterType === 'string'
}

private readonly _endingHandler = new EndingHandler()

protected async executeGuards(methodWrapper: NestJSMethodWrapper, ctx: IRestfulConnect.WebContext, params: unknown[]): Promise<void> {
const methodGuards = methodWrapper.getMethodGuards()
const controllerGuards = methodWrapper.getControllerOperator().getControllerGuards()

const controllerTarget = methodWrapper
.getControllerOperator()
.getControllerWrapper()
.getClassWrapper()
.getTarget()

const resolvedGuards = await methodWrapper.getControllerOperator()
.getControllerWrapper()
.getRestfulScanner()
.mergeAndResolveToInstance(
methodGuards,
controllerGuards,
)

const canActivate = await methodWrapper.getControllerOperator()
.getControllerWrapper()
.getRestfulScanner()
.executeGuards(
resolvedGuards,
new ExecutionContextBuilder(
params,
'http',
controllerTarget,
controllerTarget.prototype[methodWrapper.getPropertyKey()],
),
)

if (canActivate === false)
throw new UnauthorizedException()
}

async handleConnectRequest(methodOperator: IRestMethodOperator, ctx: IRestfulConnect.WebContext): Promise<void> {
if (!(methodOperator instanceof NestJSMethodOperator))
throw new Error('Method operator is not a NestJSMethodOperator')

const methodWrapper = methodOperator.getMethodWrapper()
const params = await this._buildParams(methodWrapper, ctx)

// 1. Build params with pipes
const params = await this.buildParams(methodWrapper, ctx)
// 2. Execute guards
await this.executeGuards(methodWrapper, ctx, params)
// 3. Execute the controller method
const result = await methodWrapper.execute(params, {
webContext: ctx,
adapterType: 'connect',
handlerType: 'nestjs',
})

if (ctx.response.writableEnded || ctx.response.writableFinished)
return

if ('send' in ctx.response && typeof ctx.response.send === 'function' && 'status' in ctx.response && typeof ctx.response.status === 'function') {
if (result.type === 'result') {
ctx.response.send(result.value)
}
else {
ctx.response.status(500).send({
statusCode: 500,
message: 'Internal Server Error',
error: await this._toReadableError(result.error),
})
}
return
}

if (result.type === 'result') {
ctx.response.end(await this._toSendableString(result.value))
}
else {
ctx.response.statusCode = 500
ctx.response.end(
await this._toSendableString({
statusCode: 500,
message: 'Internal Server Error',
error: await this._toReadableError(result.error),
}),
)
}
}

private async _toReadableError(error: unknown): Promise<Record<string, unknown>> {
if (error && error instanceof Error) {
const result: Record<string, unknown> = {}
if (error.name !== undefined)
result.name = error.name
for (const key of Reflect.ownKeys(error))
result[key as string] = error[key as keyof Error]
return result
}
return error as Record<string, unknown>
}

private async _stringifyAsync(data: unknown): Promise<string> {
return JSON.stringify(data)
}

private async _toSendableString(data: unknown): Promise<string> {
if (typeof data === 'string')
return data
if (typeof data === 'object' && data !== null) {
return await this._stringifyAsync(data)
.catch(async error => this._stringifyAsync({
statusCode: 500,
message: 'Internal Server Error',
error: await this._toReadableError(error),
}))
.catch(async error => this._stringifyAsync({
statusCode: 500,
message: 'Internal Server Error',
error: await this._toReadableError(error),
}))
}
return String(data)
await this._endingHandler.sendConnectEndingResponse(ctx, result)
}
}
12 changes: 11 additions & 1 deletion packages/adapter/adapter-nestjs/src/restful/restful-scanner.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ArgumentMetadata, ArgumentsHost, ExceptionFilter, PipeTransform } from '@nestjs/common'
import type { ArgumentMetadata, ArgumentsHost, CanActivate, ExceptionFilter, ExecutionContext, PipeTransform } from '@nestjs/common'
import type { IArgument } from '@unioc/core'
import type { IClass } from '@unioc/shared'
import type { IHttpParam, IRestfulConnect, IRestfulScanner } from '@unioc/web'
Expand Down Expand Up @@ -120,6 +120,16 @@ export class NestJSRestfulScanner extends RestfulScanner implements IRestfulScan
return 'no-match'
}

async executeGuards(resolvedGuards: CanActivate[], context: ExecutionContext): Promise<boolean> {
for (const guard of resolvedGuards) {
const canActivate = await guard.canActivate(context)
if (canActivate === true)
continue
return false
Comment on lines +125 to +128
Copy link
Preview

Copilot AI Apr 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The guard execution does not include error handling; if guard.canActivate throws an exception, it could lead to unhandled errors. Consider wrapping this call in a try-catch block to provide a controlled failure response.

Suggested change
const canActivate = await guard.canActivate(context)
if (canActivate === true)
continue
return false
try {
const canActivate = await guard.canActivate(context)
if (canActivate === true)
continue
return false
} catch (error) {
// Log the error or handle it as needed
console.error('Error in guard.canActivate:', error)
return false
}

Copilot uses AI. Check for mistakes.

}
return true
}

resolveConnectHandler(): IRestfulConnect.Handler {
return new NestJSRestfulHandler()
}
Expand Down
33 changes: 33 additions & 0 deletions packages/adapter/adapter-nestjs/test/guard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { CanActivate, Controller, ExecutionContext, Get, Injectable, UseGuards } from '@nestjs/common'
import { ExpressApp } from '@unioc/web-express'
import request from 'supertest'
import { NestJS } from '../src'
import 'reflect-metadata'

it('should use guard', async () => {
@Injectable()
class TestGuard implements CanActivate {
canActivate(_context: ExecutionContext): boolean {
return false
}
}

@Controller()
class TestController {
@Get('/test-guard')
@UseGuards(TestGuard)
test() {
return 'ok'
}
}

const app = new ExpressApp().use(NestJS, {
controllers: [TestController],
providers: [TestGuard],
})
await app.initialize()

await request(app.getExpressApp())
.get('/test-guard')
.expect(401)
})