refactor(server): server errors (#5741)

standardize the error raising in both GraphQL Resolvers and Controllers.

Now, All user aware errors should be throwed with `HttpException`'s variants, for example `NotFoundException`.

> Directly throwing `GraphQLError` are forbidden.
The GraphQL errorFormatter will handle it automatically and set `code`, `status` in error extensions.

At the same time, the frontend `GraphQLError` should be imported from `@affine/graphql`, which introduce a better error extensions type.

----
controller example:
```js
@Get('/docs/${id}')
doc() {
  // ...
  // imported from '@nestjs/common'
  throw new NotFoundException('Doc is not found.');
  // ...
}
```
the above will response as:
```
status: 404 Not Found
{
  "message": "Doc is not found.",
  "statusCode": 404,
  "error": "Not Found"
}
```

resolver example:
```js
@Mutation()
invite() {
  // ...
  throw new PayloadTooLargeException('Workspace seats is full.')
  // ...
}
```

the above will response as:
```
status: 200 Ok
{
  "data": null,
  "errors": [
    {
      "message": "Workspace seats is full.",
      "extensions": {
        "code": 404,
        "status": "Not Found"
      }
    }
  ]
}
```

for frontend GraphQLError user-friend, a helper function introduced:

```js
import { findGraphQLError } from '@affine/graphql'

fetch(query)
  .catch(errOrArr => {
    const e = findGraphQLError(errOrArr, e => e.extensions.code === 404)
    if (e) {
      // handle
    }
})
```
This commit is contained in:
liuyi
2024-01-31 08:43:03 +00:00
parent 72d9cc1e5b
commit 26db1d436d
22 changed files with 310 additions and 193 deletions

View File

@@ -1,4 +1,4 @@
import { BadRequestException, HttpStatus, UseGuards } from '@nestjs/common';
import { BadRequestException, UseGuards } from '@nestjs/common';
import {
Args,
Int,
@@ -8,13 +8,13 @@ import {
Resolver,
} from '@nestjs/graphql';
import type { User } from '@prisma/client';
import { GraphQLError } from 'graphql';
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import {
CloudThrottlerGuard,
EventEmitter,
type FileUpload,
PaymentRequiredException,
PrismaService,
Throttle,
} from '../../fundamentals';
@@ -97,14 +97,8 @@ export class UserResolver {
@Args('email') email?: string
) {
if (!email || !(await this.feature.canEarlyAccess(email))) {
return new GraphQLError(
`You don't have early access permission\nVisit https://community.affine.pro/c/insider-general/ for more information`,
{
extensions: {
status: HttpStatus[HttpStatus.PAYMENT_REQUIRED],
code: HttpStatus.PAYMENT_REQUIRED,
},
}
throw new PaymentRequiredException(
`You don't have early access permission\nVisit https://community.affine.pro/c/insider-general/ for more information`
);
}

View File

@@ -299,6 +299,18 @@ export class PermissionService {
return this.tryCheckWorkspace(ws, user, permission);
}
async isPublicPage(ws: string, page: string) {
return this.prisma.workspacePage
.count({
where: {
workspaceId: ws,
pageId: page,
public: true,
},
})
.then(count => count > 0);
}
async publishPage(ws: string, page: string, mode = PublicPageMode.Page) {
return this.prisma.workspacePage.upsert({
where: {
@@ -321,26 +333,19 @@ export class PermissionService {
}
async revokePublicPage(ws: string, page: string) {
const workspacePage = await this.prisma.workspacePage.findUnique({
return this.prisma.workspacePage.upsert({
where: {
workspaceId_pageId: {
workspaceId: ws,
pageId: page,
},
},
});
if (!workspacePage) {
throw new Error('Page is not public');
}
return this.prisma.workspacePage.update({
where: {
workspaceId_pageId: {
workspaceId: ws,
pageId: page,
},
update: {
public: false,
},
data: {
create: {
workspaceId: ws,
pageId: page,
public: false,
},
});

View File

@@ -1,4 +1,9 @@
import { HttpStatus, Logger, UseGuards } from '@nestjs/common';
import {
ForbiddenException,
Logger,
PayloadTooLargeException,
UseGuards,
} from '@nestjs/common';
import {
Args,
Int,
@@ -8,7 +13,6 @@ import {
ResolveField,
Resolver,
} from '@nestjs/graphql';
import { GraphQLError } from 'graphql';
import { SafeIntResolver } from 'graphql-scalars';
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
@@ -138,12 +142,7 @@ export class WorkspaceBlobResolver {
const checkExceeded = (recvSize: number) => {
if (!storageQuota) {
throw new GraphQLError('cannot find user quota', {
extensions: {
status: HttpStatus[HttpStatus.FORBIDDEN],
code: HttpStatus.FORBIDDEN,
},
});
throw new ForbiddenException('Cannot find user quota.');
}
const total = usedSize + recvSize;
// only skip total storage check if workspace has unlimited feature
@@ -163,12 +162,9 @@ export class WorkspaceBlobResolver {
};
if (checkExceeded(0)) {
throw new GraphQLError('storage or blob size limit exceeded', {
extensions: {
status: HttpStatus[HttpStatus.PAYLOAD_TOO_LARGE],
code: HttpStatus.PAYLOAD_TOO_LARGE,
},
});
throw new PayloadTooLargeException(
'Storage or blob size limit exceeded.'
);
}
const buffer = await new Promise<Buffer>((resolve, reject) => {
const stream = blob.createReadStream();
@@ -180,12 +176,7 @@ export class WorkspaceBlobResolver {
const bufferSize = chunks.reduce((acc, cur) => acc + cur.length, 0);
if (checkExceeded(bufferSize)) {
reject(
new GraphQLError('storage or blob size limit exceeded', {
extensions: {
status: HttpStatus[HttpStatus.PAYLOAD_TOO_LARGE],
code: HttpStatus.PAYLOAD_TOO_LARGE,
},
})
new PayloadTooLargeException('Storage or blob size limit exceeded.')
);
}
});
@@ -194,14 +185,7 @@ export class WorkspaceBlobResolver {
const buffer = Buffer.concat(chunks);
if (checkExceeded(buffer.length)) {
reject(
new GraphQLError('storage limit exceeded', {
extensions: {
status: HttpStatus[HttpStatus.PAYLOAD_TOO_LARGE],
code: HttpStatus.PAYLOAD_TOO_LARGE,
},
})
);
reject(new PayloadTooLargeException('Storage limit exceeded.'));
} else {
resolve(buffer);
}

View File

@@ -1,4 +1,4 @@
import { ForbiddenException, UseGuards } from '@nestjs/common';
import { BadRequestException, UseGuards } from '@nestjs/common';
import {
Args,
Field,
@@ -111,7 +111,7 @@ export class PagePermissionResolver {
const docId = new DocID(pageId, workspaceId);
if (docId.isWorkspace) {
throw new ForbiddenException('Expect page not to be workspace');
throw new BadRequestException('Expect page not to be workspace');
}
await this.permission.checkWorkspace(
@@ -148,7 +148,7 @@ export class PagePermissionResolver {
const docId = new DocID(pageId, workspaceId);
if (docId.isWorkspace) {
throw new ForbiddenException('Expect page not to be workspace');
throw new BadRequestException('Expect page not to be workspace');
}
await this.permission.checkWorkspace(
@@ -157,6 +157,15 @@ export class PagePermissionResolver {
Permission.Read
);
const isPublic = await this.permission.isPublicPage(
docId.workspace,
docId.guid
);
if (!isPublic) {
throw new BadRequestException('Page is not public');
}
return this.permission.revokePublicPage(docId.workspace, docId.guid);
}
}

View File

@@ -1,8 +1,9 @@
import {
ForbiddenException,
HttpStatus,
InternalServerErrorException,
Logger,
NotFoundException,
PayloadTooLargeException,
UseGuards,
} from '@nestjs/common';
import {
@@ -16,7 +17,6 @@ import {
} from '@nestjs/graphql';
import type { User } from '@prisma/client';
import { getStreamAsBuffer } from 'get-stream';
import { GraphQLError } from 'graphql';
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import { applyUpdate, Doc } from 'yjs';
@@ -344,12 +344,7 @@ export class WorkspaceResolver {
this.quota.getWorkspaceUsage(workspaceId),
]);
if (memberCount >= quota.memberLimit) {
throw new GraphQLError('Workspace member limit reached', {
extensions: {
status: HttpStatus[HttpStatus.PAYLOAD_TOO_LARGE],
code: HttpStatus.PAYLOAD_TOO_LARGE,
},
});
throw new PayloadTooLargeException('Workspace member limit reached.');
}
let target = await this.users.findUserByEmail(email);
@@ -401,14 +396,8 @@ export class WorkspaceResolver {
`failed to send ${workspaceId} invite email to ${email}, but successfully revoked permission: ${e}`
);
}
return new GraphQLError(
'failed to send invite email, please try again',
{
extensions: {
status: HttpStatus[HttpStatus.INTERNAL_SERVER_ERROR],
code: HttpStatus.INTERNAL_SERVER_ERROR,
},
}
return new InternalServerErrorException(
'Failed to send invite email. Please try again.'
);
}
}