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:
@@ -4,9 +4,8 @@ import type { NestExpressApplication } from '@nestjs/platform-express';
|
|||||||
import cookieParser from 'cookie-parser';
|
import cookieParser from 'cookie-parser';
|
||||||
import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs';
|
import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs';
|
||||||
|
|
||||||
import { SocketIoAdapter } from './fundamentals';
|
import { GlobalExceptionFilter } from './fundamentals';
|
||||||
import { SocketIoAdapterImpl } from './fundamentals/websocket';
|
import { SocketIoAdapter, SocketIoAdapterImpl } from './fundamentals/websocket';
|
||||||
import { ExceptionLogger } from './middleware/exception-logger';
|
|
||||||
import { serverTimingAndCache } from './middleware/timing';
|
import { serverTimingAndCache } from './middleware/timing';
|
||||||
|
|
||||||
export async function createApp() {
|
export async function createApp() {
|
||||||
@@ -29,7 +28,7 @@ export async function createApp() {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
app.useGlobalFilters(new ExceptionLogger());
|
app.useGlobalFilters(new GlobalExceptionFilter(app.getHttpAdapter()));
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
|
|
||||||
if (AFFiNE.flavor.sync) {
|
if (AFFiNE.flavor.sync) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { BadRequestException, HttpStatus, UseGuards } from '@nestjs/common';
|
import { BadRequestException, UseGuards } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
Args,
|
Args,
|
||||||
Int,
|
Int,
|
||||||
@@ -8,13 +8,13 @@ import {
|
|||||||
Resolver,
|
Resolver,
|
||||||
} from '@nestjs/graphql';
|
} from '@nestjs/graphql';
|
||||||
import type { User } from '@prisma/client';
|
import type { User } from '@prisma/client';
|
||||||
import { GraphQLError } from 'graphql';
|
|
||||||
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
|
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CloudThrottlerGuard,
|
CloudThrottlerGuard,
|
||||||
EventEmitter,
|
EventEmitter,
|
||||||
type FileUpload,
|
type FileUpload,
|
||||||
|
PaymentRequiredException,
|
||||||
PrismaService,
|
PrismaService,
|
||||||
Throttle,
|
Throttle,
|
||||||
} from '../../fundamentals';
|
} from '../../fundamentals';
|
||||||
@@ -97,14 +97,8 @@ export class UserResolver {
|
|||||||
@Args('email') email?: string
|
@Args('email') email?: string
|
||||||
) {
|
) {
|
||||||
if (!email || !(await this.feature.canEarlyAccess(email))) {
|
if (!email || !(await this.feature.canEarlyAccess(email))) {
|
||||||
return new GraphQLError(
|
throw new PaymentRequiredException(
|
||||||
`You don't have early access permission\nVisit https://community.affine.pro/c/insider-general/ for more information`,
|
`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,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -299,6 +299,18 @@ export class PermissionService {
|
|||||||
return this.tryCheckWorkspace(ws, user, permission);
|
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) {
|
async publishPage(ws: string, page: string, mode = PublicPageMode.Page) {
|
||||||
return this.prisma.workspacePage.upsert({
|
return this.prisma.workspacePage.upsert({
|
||||||
where: {
|
where: {
|
||||||
@@ -321,26 +333,19 @@ export class PermissionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async revokePublicPage(ws: string, page: string) {
|
async revokePublicPage(ws: string, page: string) {
|
||||||
const workspacePage = await this.prisma.workspacePage.findUnique({
|
return this.prisma.workspacePage.upsert({
|
||||||
where: {
|
where: {
|
||||||
workspaceId_pageId: {
|
workspaceId_pageId: {
|
||||||
workspaceId: ws,
|
workspaceId: ws,
|
||||||
pageId: page,
|
pageId: page,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
update: {
|
||||||
if (!workspacePage) {
|
public: false,
|
||||||
throw new Error('Page is not public');
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.prisma.workspacePage.update({
|
|
||||||
where: {
|
|
||||||
workspaceId_pageId: {
|
|
||||||
workspaceId: ws,
|
|
||||||
pageId: page,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
data: {
|
create: {
|
||||||
|
workspaceId: ws,
|
||||||
|
pageId: page,
|
||||||
public: false,
|
public: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { HttpStatus, Logger, UseGuards } from '@nestjs/common';
|
import {
|
||||||
|
ForbiddenException,
|
||||||
|
Logger,
|
||||||
|
PayloadTooLargeException,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
Args,
|
Args,
|
||||||
Int,
|
Int,
|
||||||
@@ -8,7 +13,6 @@ import {
|
|||||||
ResolveField,
|
ResolveField,
|
||||||
Resolver,
|
Resolver,
|
||||||
} from '@nestjs/graphql';
|
} from '@nestjs/graphql';
|
||||||
import { GraphQLError } from 'graphql';
|
|
||||||
import { SafeIntResolver } from 'graphql-scalars';
|
import { SafeIntResolver } from 'graphql-scalars';
|
||||||
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
|
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
|
||||||
|
|
||||||
@@ -138,12 +142,7 @@ export class WorkspaceBlobResolver {
|
|||||||
|
|
||||||
const checkExceeded = (recvSize: number) => {
|
const checkExceeded = (recvSize: number) => {
|
||||||
if (!storageQuota) {
|
if (!storageQuota) {
|
||||||
throw new GraphQLError('cannot find user quota', {
|
throw new ForbiddenException('Cannot find user quota.');
|
||||||
extensions: {
|
|
||||||
status: HttpStatus[HttpStatus.FORBIDDEN],
|
|
||||||
code: HttpStatus.FORBIDDEN,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
const total = usedSize + recvSize;
|
const total = usedSize + recvSize;
|
||||||
// only skip total storage check if workspace has unlimited feature
|
// only skip total storage check if workspace has unlimited feature
|
||||||
@@ -163,12 +162,9 @@ export class WorkspaceBlobResolver {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (checkExceeded(0)) {
|
if (checkExceeded(0)) {
|
||||||
throw new GraphQLError('storage or blob size limit exceeded', {
|
throw new PayloadTooLargeException(
|
||||||
extensions: {
|
'Storage or blob size limit exceeded.'
|
||||||
status: HttpStatus[HttpStatus.PAYLOAD_TOO_LARGE],
|
);
|
||||||
code: HttpStatus.PAYLOAD_TOO_LARGE,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
const buffer = await new Promise<Buffer>((resolve, reject) => {
|
const buffer = await new Promise<Buffer>((resolve, reject) => {
|
||||||
const stream = blob.createReadStream();
|
const stream = blob.createReadStream();
|
||||||
@@ -180,12 +176,7 @@ export class WorkspaceBlobResolver {
|
|||||||
const bufferSize = chunks.reduce((acc, cur) => acc + cur.length, 0);
|
const bufferSize = chunks.reduce((acc, cur) => acc + cur.length, 0);
|
||||||
if (checkExceeded(bufferSize)) {
|
if (checkExceeded(bufferSize)) {
|
||||||
reject(
|
reject(
|
||||||
new GraphQLError('storage or blob size limit exceeded', {
|
new PayloadTooLargeException('Storage or blob size limit exceeded.')
|
||||||
extensions: {
|
|
||||||
status: HttpStatus[HttpStatus.PAYLOAD_TOO_LARGE],
|
|
||||||
code: HttpStatus.PAYLOAD_TOO_LARGE,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -194,14 +185,7 @@ export class WorkspaceBlobResolver {
|
|||||||
const buffer = Buffer.concat(chunks);
|
const buffer = Buffer.concat(chunks);
|
||||||
|
|
||||||
if (checkExceeded(buffer.length)) {
|
if (checkExceeded(buffer.length)) {
|
||||||
reject(
|
reject(new PayloadTooLargeException('Storage limit exceeded.'));
|
||||||
new GraphQLError('storage limit exceeded', {
|
|
||||||
extensions: {
|
|
||||||
status: HttpStatus[HttpStatus.PAYLOAD_TOO_LARGE],
|
|
||||||
code: HttpStatus.PAYLOAD_TOO_LARGE,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
resolve(buffer);
|
resolve(buffer);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ForbiddenException, UseGuards } from '@nestjs/common';
|
import { BadRequestException, UseGuards } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
Args,
|
Args,
|
||||||
Field,
|
Field,
|
||||||
@@ -111,7 +111,7 @@ export class PagePermissionResolver {
|
|||||||
const docId = new DocID(pageId, workspaceId);
|
const docId = new DocID(pageId, workspaceId);
|
||||||
|
|
||||||
if (docId.isWorkspace) {
|
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(
|
await this.permission.checkWorkspace(
|
||||||
@@ -148,7 +148,7 @@ export class PagePermissionResolver {
|
|||||||
const docId = new DocID(pageId, workspaceId);
|
const docId = new DocID(pageId, workspaceId);
|
||||||
|
|
||||||
if (docId.isWorkspace) {
|
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(
|
await this.permission.checkWorkspace(
|
||||||
@@ -157,6 +157,15 @@ export class PagePermissionResolver {
|
|||||||
Permission.Read
|
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);
|
return this.permission.revokePublicPage(docId.workspace, docId.guid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
HttpStatus,
|
InternalServerErrorException,
|
||||||
Logger,
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
|
PayloadTooLargeException,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
@@ -16,7 +17,6 @@ import {
|
|||||||
} from '@nestjs/graphql';
|
} from '@nestjs/graphql';
|
||||||
import type { User } from '@prisma/client';
|
import type { User } from '@prisma/client';
|
||||||
import { getStreamAsBuffer } from 'get-stream';
|
import { getStreamAsBuffer } from 'get-stream';
|
||||||
import { GraphQLError } from 'graphql';
|
|
||||||
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
|
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
|
||||||
import { applyUpdate, Doc } from 'yjs';
|
import { applyUpdate, Doc } from 'yjs';
|
||||||
|
|
||||||
@@ -344,12 +344,7 @@ export class WorkspaceResolver {
|
|||||||
this.quota.getWorkspaceUsage(workspaceId),
|
this.quota.getWorkspaceUsage(workspaceId),
|
||||||
]);
|
]);
|
||||||
if (memberCount >= quota.memberLimit) {
|
if (memberCount >= quota.memberLimit) {
|
||||||
throw new GraphQLError('Workspace member limit reached', {
|
throw new PayloadTooLargeException('Workspace member limit reached.');
|
||||||
extensions: {
|
|
||||||
status: HttpStatus[HttpStatus.PAYLOAD_TOO_LARGE],
|
|
||||||
code: HttpStatus.PAYLOAD_TOO_LARGE,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let target = await this.users.findUserByEmail(email);
|
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}`
|
`failed to send ${workspaceId} invite email to ${email}, but successfully revoked permission: ${e}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return new GraphQLError(
|
return new InternalServerErrorException(
|
||||||
'failed to send invite email, please try again',
|
'Failed to send invite email. Please try again.'
|
||||||
{
|
|
||||||
extensions: {
|
|
||||||
status: HttpStatus[HttpStatus.INTERNAL_SERVER_ERROR],
|
|
||||||
code: HttpStatus.INTERNAL_SERVER_ERROR,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1
packages/backend/server/src/fundamentals/error/index.ts
Normal file
1
packages/backend/server/src/fundamentals/error/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './payment-required';
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||||
|
|
||||||
|
export class PaymentRequiredException extends HttpException {
|
||||||
|
constructor(desc?: string, code: string = 'Payment Required') {
|
||||||
|
super(
|
||||||
|
HttpException.createBody(desc ?? code, code, HttpStatus.PAYMENT_REQUIRED),
|
||||||
|
HttpStatus.PAYMENT_REQUIRED
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,9 +3,10 @@ import { fileURLToPath } from 'node:url';
|
|||||||
|
|
||||||
import type { ApolloDriverConfig } from '@nestjs/apollo';
|
import type { ApolloDriverConfig } from '@nestjs/apollo';
|
||||||
import { ApolloDriver } from '@nestjs/apollo';
|
import { ApolloDriver } from '@nestjs/apollo';
|
||||||
import { Global, Module } from '@nestjs/common';
|
import { Global, HttpException, HttpStatus, Module } from '@nestjs/common';
|
||||||
import { GraphQLModule } from '@nestjs/graphql';
|
import { GraphQLModule } from '@nestjs/graphql';
|
||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
|
import { GraphQLError } from 'graphql';
|
||||||
|
|
||||||
import { Config } from '../config';
|
import { Config } from '../config';
|
||||||
import { GQLLoggerPlugin } from './logger-plugin';
|
import { GQLLoggerPlugin } from './logger-plugin';
|
||||||
@@ -34,7 +35,37 @@ import { GQLLoggerPlugin } from './logger-plugin';
|
|||||||
res,
|
res,
|
||||||
isAdminQuery: false,
|
isAdminQuery: false,
|
||||||
}),
|
}),
|
||||||
|
includeStacktraceInErrorResponses: !config.node.prod,
|
||||||
plugins: [new GQLLoggerPlugin()],
|
plugins: [new GQLLoggerPlugin()],
|
||||||
|
formatError: (formattedError, error) => {
|
||||||
|
// @ts-expect-error allow assign
|
||||||
|
formattedError.extensions ??= {};
|
||||||
|
|
||||||
|
if (
|
||||||
|
error instanceof GraphQLError &&
|
||||||
|
error.originalError instanceof HttpException
|
||||||
|
) {
|
||||||
|
const statusCode = error.originalError.getStatus();
|
||||||
|
const statusName = HttpStatus[statusCode];
|
||||||
|
|
||||||
|
// originally be 'INTERNAL_SERVER_ERROR'
|
||||||
|
formattedError.extensions['code'] = statusCode;
|
||||||
|
formattedError.extensions['status'] = statusName;
|
||||||
|
delete formattedError.extensions['originalError'];
|
||||||
|
|
||||||
|
return formattedError;
|
||||||
|
} else {
|
||||||
|
// @ts-expect-error allow assign
|
||||||
|
formattedError.message = 'Internal Server Error';
|
||||||
|
|
||||||
|
formattedError.extensions['code'] =
|
||||||
|
HttpStatus.INTERNAL_SERVER_ERROR;
|
||||||
|
formattedError.extensions['status'] =
|
||||||
|
HttpStatus[HttpStatus.INTERNAL_SERVER_ERROR];
|
||||||
|
}
|
||||||
|
|
||||||
|
return formattedError;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
inject: [Config],
|
inject: [Config],
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
GraphQLRequestListener,
|
GraphQLRequestListener,
|
||||||
} from '@apollo/server';
|
} from '@apollo/server';
|
||||||
import { Plugin } from '@nestjs/apollo';
|
import { Plugin } from '@nestjs/apollo';
|
||||||
import { Logger } from '@nestjs/common';
|
import { HttpException, Logger } from '@nestjs/common';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
|
|
||||||
import { metrics } from '../metrics/metrics';
|
import { metrics } from '../metrics/metrics';
|
||||||
@@ -27,28 +27,44 @@ export class GQLLoggerPlugin implements ApolloServerPlugin {
|
|||||||
|
|
||||||
metrics.gql.counter('query_counter').add(1, { operation });
|
metrics.gql.counter('query_counter').add(1, { operation });
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
|
function endTimer() {
|
||||||
|
return Date.now() - start;
|
||||||
|
}
|
||||||
|
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
willSendResponse: () => {
|
willSendResponse: () => {
|
||||||
const costInMilliseconds = Date.now() - start;
|
const time = endTimer();
|
||||||
res.setHeader(
|
res.setHeader('Server-Timing', `gql;dur=${time};desc="GraphQL"`);
|
||||||
'Server-Timing',
|
metrics.gql.histogram('query_duration').record(time, { operation });
|
||||||
`gql;dur=${costInMilliseconds};desc="GraphQL"`
|
|
||||||
);
|
|
||||||
metrics.gql
|
|
||||||
.histogram('query_duration')
|
|
||||||
.record(costInMilliseconds, { operation });
|
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
},
|
},
|
||||||
didEncounterErrors: () => {
|
didEncounterErrors: ctx => {
|
||||||
const costInMilliseconds = Date.now() - start;
|
metrics.gql.counter('query_error_counter').add(1, { operation });
|
||||||
res.setHeader(
|
|
||||||
'Server-Timing',
|
ctx.errors.forEach(err => {
|
||||||
`gql;dur=${costInMilliseconds};desc="GraphQL ${operation}"`
|
// only log non-user errors
|
||||||
);
|
let msg: string | undefined;
|
||||||
metrics.gql
|
|
||||||
.histogram('query_duration')
|
if (!err.originalError) {
|
||||||
.record(costInMilliseconds, { operation });
|
msg = err.toString();
|
||||||
|
} else {
|
||||||
|
const originalError = err.originalError;
|
||||||
|
|
||||||
|
// do not log client errors, and put more information in the error extensions.
|
||||||
|
if (!(originalError instanceof HttpException)) {
|
||||||
|
if (originalError.cause && originalError.cause instanceof Error) {
|
||||||
|
msg = originalError.cause.stack ?? originalError.cause.message;
|
||||||
|
} else {
|
||||||
|
msg = originalError.stack ?? originalError.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg) {
|
||||||
|
this.logger.error('GraphQL Unhandled Error', msg);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,10 +11,15 @@ export {
|
|||||||
type ConfigPaths,
|
type ConfigPaths,
|
||||||
getDefaultAFFiNEStorageConfig,
|
getDefaultAFFiNEStorageConfig,
|
||||||
} from './config';
|
} from './config';
|
||||||
|
export * from './error';
|
||||||
export { EventEmitter, type EventPayload, OnEvent } from './event';
|
export { EventEmitter, type EventPayload, OnEvent } from './event';
|
||||||
export { MailService } from './mailer';
|
export { MailService } from './mailer';
|
||||||
export { CallCounter, CallTimer, metrics } from './metrics';
|
export { CallCounter, CallTimer, metrics } from './metrics';
|
||||||
export { getOptionalModuleMetadata, OptionalModule } from './nestjs';
|
export {
|
||||||
|
getOptionalModuleMetadata,
|
||||||
|
GlobalExceptionFilter,
|
||||||
|
OptionalModule,
|
||||||
|
} from './nestjs';
|
||||||
export { PrismaService } from './prisma';
|
export { PrismaService } from './prisma';
|
||||||
export { SessionService } from './session';
|
export { SessionService } from './session';
|
||||||
export * from './storage';
|
export * from './storage';
|
||||||
@@ -25,4 +30,3 @@ export {
|
|||||||
getRequestResponseFromHost,
|
getRequestResponseFromHost,
|
||||||
} from './utils/request';
|
} from './utils/request';
|
||||||
export type * from './utils/types';
|
export type * from './utils/types';
|
||||||
export { SocketIoAdapter } from './websocket';
|
|
||||||
|
|||||||
25
packages/backend/server/src/fundamentals/nestjs/exception.ts
Normal file
25
packages/backend/server/src/fundamentals/nestjs/exception.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { ArgumentsHost, Catch, HttpException } from '@nestjs/common';
|
||||||
|
import { BaseExceptionFilter } from '@nestjs/core';
|
||||||
|
import { GqlContextType } from '@nestjs/graphql';
|
||||||
|
import { Response } from 'express';
|
||||||
|
|
||||||
|
@Catch()
|
||||||
|
export class GlobalExceptionFilter extends BaseExceptionFilter {
|
||||||
|
override catch(exception: Error, host: ArgumentsHost) {
|
||||||
|
// with useGlobalFilters, the context is always HTTP
|
||||||
|
|
||||||
|
if (host.getType<GqlContextType>() === 'graphql') {
|
||||||
|
// let Graphql LoggerPlugin handle it
|
||||||
|
// see '../graphql/logger-plugin.ts'
|
||||||
|
throw exception;
|
||||||
|
} else {
|
||||||
|
if (exception instanceof HttpException) {
|
||||||
|
const res = host.switchToHttp().getResponse<Response>();
|
||||||
|
res.status(exception.getStatus()).send(exception.getResponse());
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
super.catch(exception, host);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +1,2 @@
|
|||||||
|
export * from './exception';
|
||||||
export * from './optional-module';
|
export * from './optional-module';
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
import {
|
|
||||||
ArgumentsHost,
|
|
||||||
Catch,
|
|
||||||
ExceptionFilter,
|
|
||||||
HttpException,
|
|
||||||
Logger,
|
|
||||||
NotFoundException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { GqlContextType } from '@nestjs/graphql';
|
|
||||||
import { Request, Response } from 'express';
|
|
||||||
|
|
||||||
const TrivialExceptions = [NotFoundException];
|
|
||||||
|
|
||||||
export const REQUEST_ID_HEADER = 'x-request-id';
|
|
||||||
|
|
||||||
@Catch()
|
|
||||||
export class ExceptionLogger implements ExceptionFilter {
|
|
||||||
private readonly logger = new Logger('ExceptionLogger');
|
|
||||||
|
|
||||||
catch(exception: Error, host: ArgumentsHost) {
|
|
||||||
// with useGlobalFilters, the context is always HTTP
|
|
||||||
const ctx = host.switchToHttp();
|
|
||||||
|
|
||||||
const request = ctx.getRequest<Request>();
|
|
||||||
const requestId = request?.header(REQUEST_ID_HEADER);
|
|
||||||
|
|
||||||
const shouldVerboseLog = !TrivialExceptions.some(
|
|
||||||
e => exception instanceof e
|
|
||||||
);
|
|
||||||
this.logger.error(
|
|
||||||
new Error(
|
|
||||||
`${requestId ? `requestId-${requestId}: ` : ''}${exception.message}${
|
|
||||||
shouldVerboseLog ? '\n' + exception.stack : ''
|
|
||||||
}`,
|
|
||||||
{ cause: exception }
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (host.getType<GqlContextType>() === 'graphql') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = ctx.getResponse<Response>();
|
|
||||||
if (exception instanceof HttpException) {
|
|
||||||
response.status(exception.getStatus()).json(exception.getResponse());
|
|
||||||
} else {
|
|
||||||
response.status(500).json({
|
|
||||||
statusCode: 500,
|
|
||||||
error: exception.message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -21,7 +21,5 @@ export const serverTimingAndCache = (
|
|||||||
res.setHeader('Server-Timing', serverTimingValue);
|
res.setHeader('Server-Timing', serverTimingValue);
|
||||||
});
|
});
|
||||||
|
|
||||||
res.setHeader('Cache-Control', 'max-age=0, private, must-revalidate');
|
|
||||||
|
|
||||||
next();
|
next();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { HttpStatus } from '@nestjs/common';
|
import {
|
||||||
|
BadGatewayException,
|
||||||
|
ForbiddenException,
|
||||||
|
InternalServerErrorException,
|
||||||
|
} from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
Args,
|
Args,
|
||||||
Context,
|
Context,
|
||||||
@@ -13,7 +17,6 @@ import {
|
|||||||
Resolver,
|
Resolver,
|
||||||
} from '@nestjs/graphql';
|
} from '@nestjs/graphql';
|
||||||
import type { User, UserInvoice, UserSubscription } from '@prisma/client';
|
import type { User, UserInvoice, UserSubscription } from '@prisma/client';
|
||||||
import { GraphQLError } from 'graphql';
|
|
||||||
import { groupBy } from 'lodash-es';
|
import { groupBy } from 'lodash-es';
|
||||||
|
|
||||||
import { Auth, CurrentUser, Public } from '../../core/auth';
|
import { Auth, CurrentUser, Public } from '../../core/auth';
|
||||||
@@ -164,12 +167,9 @@ export class SubscriptionResolver {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!yearly || !monthly) {
|
if (!yearly || !monthly) {
|
||||||
throw new GraphQLError('The prices are not configured correctly', {
|
throw new InternalServerErrorException(
|
||||||
extensions: {
|
'The prices are not configured correctly.'
|
||||||
status: HttpStatus[HttpStatus.BAD_GATEWAY],
|
);
|
||||||
code: HttpStatus.BAD_GATEWAY,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -199,12 +199,7 @@ export class SubscriptionResolver {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!session.url) {
|
if (!session.url) {
|
||||||
throw new GraphQLError('Failed to create checkout session', {
|
throw new BadGatewayException('Failed to create checkout session.');
|
||||||
extensions: {
|
|
||||||
status: HttpStatus[HttpStatus.BAD_GATEWAY],
|
|
||||||
code: HttpStatus.BAD_GATEWAY,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return session.url;
|
return session.url;
|
||||||
@@ -263,14 +258,8 @@ export class UserSubscriptionResolver {
|
|||||||
) {
|
) {
|
||||||
// allow admin to query other user's subscription
|
// allow admin to query other user's subscription
|
||||||
if (!ctx.isAdminQuery && me.id !== user.id) {
|
if (!ctx.isAdminQuery && me.id !== user.id) {
|
||||||
throw new GraphQLError(
|
throw new ForbiddenException(
|
||||||
'You are not allowed to access this subscription',
|
'You are not allowed to access this subscription.'
|
||||||
{
|
|
||||||
extensions: {
|
|
||||||
status: HttpStatus[HttpStatus.FORBIDDEN],
|
|
||||||
code: HttpStatus.FORBIDDEN,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,12 +299,9 @@ export class UserSubscriptionResolver {
|
|||||||
@Args('skip', { type: () => Int, nullable: true }) skip?: number
|
@Args('skip', { type: () => Int, nullable: true }) skip?: number
|
||||||
) {
|
) {
|
||||||
if (me.id !== user.id) {
|
if (me.id !== user.id) {
|
||||||
throw new GraphQLError('You are not allowed to access this invoices', {
|
throw new ForbiddenException(
|
||||||
extensions: {
|
'You are not allowed to access this invoices'
|
||||||
status: HttpStatus[HttpStatus.FORBIDDEN],
|
);
|
||||||
code: HttpStatus.FORBIDDEN,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.db.userInvoice.findMany({
|
return this.db.userInvoice.findMany({
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { createAdapter } from '@socket.io/redis-adapter';
|
|||||||
import { Redis } from 'ioredis';
|
import { Redis } from 'ioredis';
|
||||||
import { Server, ServerOptions } from 'socket.io';
|
import { Server, ServerOptions } from 'socket.io';
|
||||||
|
|
||||||
import { SocketIoAdapter } from '../../fundamentals';
|
import { SocketIoAdapter } from '../../fundamentals/websocket';
|
||||||
|
|
||||||
export function createSockerIoAdapterImpl(
|
export function createSockerIoAdapterImpl(
|
||||||
redis: Redis
|
redis: Redis
|
||||||
|
|||||||
90
packages/backend/server/tests/graphql.spec.ts
Normal file
90
packages/backend/server/tests/graphql.spec.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import {
|
||||||
|
ForbiddenException,
|
||||||
|
HttpStatus,
|
||||||
|
INestApplication,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import testFn, { TestFn } from 'ava';
|
||||||
|
import request from 'supertest';
|
||||||
|
|
||||||
|
import { ConfigModule } from '../src/fundamentals/config';
|
||||||
|
import { GqlModule } from '../src/fundamentals/graphql';
|
||||||
|
|
||||||
|
@Resolver(() => String)
|
||||||
|
class TestResolver {
|
||||||
|
greating = 'hello world';
|
||||||
|
|
||||||
|
@Query(() => String)
|
||||||
|
hello() {
|
||||||
|
return this.greating;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Mutation(() => String)
|
||||||
|
update(@Args('greating') greating: string) {
|
||||||
|
this.greating = greating;
|
||||||
|
return this.greating;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Query(() => String)
|
||||||
|
errorQuery() {
|
||||||
|
throw new ForbiddenException('forbidden query');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Query(() => String)
|
||||||
|
unknownErrorQuery() {
|
||||||
|
throw new Error('unknown error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const test = testFn as TestFn<{ app: INestApplication }>;
|
||||||
|
|
||||||
|
function gql(app: INestApplication, query: string) {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.post('/graphql')
|
||||||
|
.send({ query })
|
||||||
|
.expect(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.beforeEach(async ctx => {
|
||||||
|
const module = await Test.createTestingModule({
|
||||||
|
imports: [ConfigModule.forRoot(), GqlModule],
|
||||||
|
providers: [TestResolver],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
ctx.context.app = await module
|
||||||
|
.createNestApplication({
|
||||||
|
logger: false,
|
||||||
|
})
|
||||||
|
.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should be able to execute query', async t => {
|
||||||
|
const res = await gql(t.context.app, `query { hello }`);
|
||||||
|
t.is(res.body.data.hello, 'hello world');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should be able to execute mutation', async t => {
|
||||||
|
const res = await gql(t.context.app, `mutation { update(greating: "hi") }`);
|
||||||
|
|
||||||
|
t.is(res.body.data.update, 'hi');
|
||||||
|
|
||||||
|
const newRes = await gql(t.context.app, `query { hello }`);
|
||||||
|
t.is(newRes.body.data.hello, 'hi');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should be able to handle known http exception', async t => {
|
||||||
|
const res = await gql(t.context.app, `query { errorQuery }`);
|
||||||
|
const err = res.body.errors[0];
|
||||||
|
t.is(err.message, 'forbidden query');
|
||||||
|
t.is(err.extensions.code, HttpStatus.FORBIDDEN);
|
||||||
|
t.is(err.extensions.status, HttpStatus[HttpStatus.FORBIDDEN]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should be able to handle unknown internal error', async t => {
|
||||||
|
const res = await gql(t.context.app, `query { unknownErrorQuery }`);
|
||||||
|
const err = res.body.errors[0];
|
||||||
|
t.is(err.message, 'Internal Server Error');
|
||||||
|
t.is(err.extensions.code, HttpStatus.INTERNAL_SERVER_ERROR);
|
||||||
|
t.is(err.extensions.status, HttpStatus[HttpStatus.INTERNAL_SERVER_ERROR]);
|
||||||
|
});
|
||||||
@@ -5,11 +5,14 @@ import {
|
|||||||
} from '@affine/component/auth-components';
|
} from '@affine/component/auth-components';
|
||||||
import { Button } from '@affine/component/ui/button';
|
import { Button } from '@affine/component/ui/button';
|
||||||
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
|
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
|
||||||
import { type GetUserQuery, getUserQuery } from '@affine/graphql';
|
import {
|
||||||
|
findGraphQLError,
|
||||||
|
type GetUserQuery,
|
||||||
|
getUserQuery,
|
||||||
|
} from '@affine/graphql';
|
||||||
import { Trans } from '@affine/i18n';
|
import { Trans } from '@affine/i18n';
|
||||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||||
import { ArrowDownBigIcon, GoogleDuotoneIcon } from '@blocksuite/icons';
|
import { ArrowDownBigIcon, GoogleDuotoneIcon } from '@blocksuite/icons';
|
||||||
import { GraphQLError } from 'graphql';
|
|
||||||
import { type FC, useState } from 'react';
|
import { type FC, useState } from 'react';
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
@@ -64,8 +67,7 @@ export const SignIn: FC<AuthPanelProps> = ({
|
|||||||
const user: GetUserQuery['user'] | null | 0 = await verifyUser({ email })
|
const user: GetUserQuery['user'] | null | 0 = await verifyUser({ email })
|
||||||
.then(({ user }) => user)
|
.then(({ user }) => user)
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
const e = err?.[0];
|
if (findGraphQLError(err, e => e.extensions.code === 402)) {
|
||||||
if (e instanceof GraphQLError && e.extensions?.code === 402) {
|
|
||||||
setAuthState('noAccess');
|
setAuthState('noAccess');
|
||||||
return 0;
|
return 0;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
26
packages/frontend/graphql/src/error.ts
Normal file
26
packages/frontend/graphql/src/error.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { GraphQLError as BaseGraphQLError } from 'graphql';
|
||||||
|
import { identity } from 'lodash-es';
|
||||||
|
|
||||||
|
interface KnownGraphQLErrorExtensions {
|
||||||
|
code: number;
|
||||||
|
status: string;
|
||||||
|
originalError?: unknown;
|
||||||
|
stacktrace?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GraphQLError extends BaseGraphQLError {
|
||||||
|
// @ts-expect-error better to be a known type without any type casting
|
||||||
|
override extensions!: KnownGraphQLErrorExtensions;
|
||||||
|
}
|
||||||
|
export function findGraphQLError(
|
||||||
|
errOrArr: any,
|
||||||
|
filter: (err: GraphQLError) => boolean = identity
|
||||||
|
): GraphQLError | undefined {
|
||||||
|
if (errOrArr instanceof GraphQLError) {
|
||||||
|
return filter(errOrArr) ? errOrArr : undefined;
|
||||||
|
} else if (Array.isArray(errOrArr)) {
|
||||||
|
return errOrArr.find(err => err instanceof GraphQLError && filter(err));
|
||||||
|
} else {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export * from './error';
|
||||||
export * from './fetcher';
|
export * from './fetcher';
|
||||||
export * from './graphql';
|
export * from './graphql';
|
||||||
export * from './schema';
|
export * from './schema';
|
||||||
@@ -18,5 +19,3 @@ export function getBaseUrl(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const fetcher = gqlFetcherFactory(getBaseUrl() + '/graphql');
|
export const fetcher = gqlFetcherFactory(getBaseUrl() + '/graphql');
|
||||||
|
|
||||||
export { GraphQLError } from 'graphql';
|
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
deleteBlobMutation,
|
deleteBlobMutation,
|
||||||
fetchWithTraceReport,
|
fetchWithTraceReport,
|
||||||
|
findGraphQLError,
|
||||||
getBaseUrl,
|
getBaseUrl,
|
||||||
GraphQLError,
|
|
||||||
listBlobsQuery,
|
listBlobsQuery,
|
||||||
setBlobMutation,
|
setBlobMutation,
|
||||||
} from '@affine/graphql';
|
} from '@affine/graphql';
|
||||||
import { fetcher } from '@affine/graphql';
|
import { fetcher } from '@affine/graphql';
|
||||||
import { type BlobStorage, BlobStorageOverCapacity } from '@toeverything/infra';
|
import { type BlobStorage, BlobStorageOverCapacity } from '@toeverything/infra';
|
||||||
import { isArray } from 'lodash-es';
|
|
||||||
|
|
||||||
import { bufferToBlob } from '../utils/buffer-to-blob';
|
import { bufferToBlob } from '../utils/buffer-to-blob';
|
||||||
|
|
||||||
@@ -43,13 +42,15 @@ export class AffineCloudBlobStorage implements BlobStorage {
|
|||||||
})
|
})
|
||||||
.then(res => res.setBlob)
|
.then(res => res.setBlob)
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
if (isArray(err)) {
|
const uploadError = findGraphQLError(
|
||||||
err.map(e => {
|
err,
|
||||||
if (e instanceof GraphQLError && e.extensions.code === 413) {
|
e => e.extensions.code === 413
|
||||||
throw new BlobStorageOverCapacity(e);
|
);
|
||||||
} else throw e;
|
|
||||||
});
|
if (uploadError) {
|
||||||
|
throw new BlobStorageOverCapacity(uploadError);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw err;
|
throw err;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user