mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-23 21:38:44 +08:00
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 graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs';
|
||||
|
||||
import { SocketIoAdapter } from './fundamentals';
|
||||
import { SocketIoAdapterImpl } from './fundamentals/websocket';
|
||||
import { ExceptionLogger } from './middleware/exception-logger';
|
||||
import { GlobalExceptionFilter } from './fundamentals';
|
||||
import { SocketIoAdapter, SocketIoAdapterImpl } from './fundamentals/websocket';
|
||||
import { serverTimingAndCache } from './middleware/timing';
|
||||
|
||||
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());
|
||||
|
||||
if (AFFiNE.flavor.sync) {
|
||||
|
||||
@@ -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`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 { ApolloDriver } from '@nestjs/apollo';
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { Global, HttpException, HttpStatus, Module } from '@nestjs/common';
|
||||
import { GraphQLModule } from '@nestjs/graphql';
|
||||
import { Request, Response } from 'express';
|
||||
import { GraphQLError } from 'graphql';
|
||||
|
||||
import { Config } from '../config';
|
||||
import { GQLLoggerPlugin } from './logger-plugin';
|
||||
@@ -34,7 +35,37 @@ import { GQLLoggerPlugin } from './logger-plugin';
|
||||
res,
|
||||
isAdminQuery: false,
|
||||
}),
|
||||
includeStacktraceInErrorResponses: !config.node.prod,
|
||||
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],
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
GraphQLRequestListener,
|
||||
} from '@apollo/server';
|
||||
import { Plugin } from '@nestjs/apollo';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { HttpException, Logger } from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
|
||||
import { metrics } from '../metrics/metrics';
|
||||
@@ -27,28 +27,44 @@ export class GQLLoggerPlugin implements ApolloServerPlugin {
|
||||
|
||||
metrics.gql.counter('query_counter').add(1, { operation });
|
||||
const start = Date.now();
|
||||
function endTimer() {
|
||||
return Date.now() - start;
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
willSendResponse: () => {
|
||||
const costInMilliseconds = Date.now() - start;
|
||||
res.setHeader(
|
||||
'Server-Timing',
|
||||
`gql;dur=${costInMilliseconds};desc="GraphQL"`
|
||||
);
|
||||
metrics.gql
|
||||
.histogram('query_duration')
|
||||
.record(costInMilliseconds, { operation });
|
||||
const time = endTimer();
|
||||
res.setHeader('Server-Timing', `gql;dur=${time};desc="GraphQL"`);
|
||||
metrics.gql.histogram('query_duration').record(time, { operation });
|
||||
return Promise.resolve();
|
||||
},
|
||||
didEncounterErrors: () => {
|
||||
const costInMilliseconds = Date.now() - start;
|
||||
res.setHeader(
|
||||
'Server-Timing',
|
||||
`gql;dur=${costInMilliseconds};desc="GraphQL ${operation}"`
|
||||
);
|
||||
metrics.gql
|
||||
.histogram('query_duration')
|
||||
.record(costInMilliseconds, { operation });
|
||||
didEncounterErrors: ctx => {
|
||||
metrics.gql.counter('query_error_counter').add(1, { operation });
|
||||
|
||||
ctx.errors.forEach(err => {
|
||||
// only log non-user errors
|
||||
let msg: string | undefined;
|
||||
|
||||
if (!err.originalError) {
|
||||
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();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -11,10 +11,15 @@ export {
|
||||
type ConfigPaths,
|
||||
getDefaultAFFiNEStorageConfig,
|
||||
} from './config';
|
||||
export * from './error';
|
||||
export { EventEmitter, type EventPayload, OnEvent } from './event';
|
||||
export { MailService } from './mailer';
|
||||
export { CallCounter, CallTimer, metrics } from './metrics';
|
||||
export { getOptionalModuleMetadata, OptionalModule } from './nestjs';
|
||||
export {
|
||||
getOptionalModuleMetadata,
|
||||
GlobalExceptionFilter,
|
||||
OptionalModule,
|
||||
} from './nestjs';
|
||||
export { PrismaService } from './prisma';
|
||||
export { SessionService } from './session';
|
||||
export * from './storage';
|
||||
@@ -25,4 +30,3 @@ export {
|
||||
getRequestResponseFromHost,
|
||||
} from './utils/request';
|
||||
export type * from './utils/types';
|
||||
export { SocketIoAdapter } from './websocket';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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('Cache-Control', 'max-age=0, private, must-revalidate');
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { HttpStatus } from '@nestjs/common';
|
||||
import {
|
||||
BadGatewayException,
|
||||
ForbiddenException,
|
||||
InternalServerErrorException,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
Args,
|
||||
Context,
|
||||
@@ -13,7 +17,6 @@ import {
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
import type { User, UserInvoice, UserSubscription } from '@prisma/client';
|
||||
import { GraphQLError } from 'graphql';
|
||||
import { groupBy } from 'lodash-es';
|
||||
|
||||
import { Auth, CurrentUser, Public } from '../../core/auth';
|
||||
@@ -164,12 +167,9 @@ export class SubscriptionResolver {
|
||||
);
|
||||
|
||||
if (!yearly || !monthly) {
|
||||
throw new GraphQLError('The prices are not configured correctly', {
|
||||
extensions: {
|
||||
status: HttpStatus[HttpStatus.BAD_GATEWAY],
|
||||
code: HttpStatus.BAD_GATEWAY,
|
||||
},
|
||||
});
|
||||
throw new InternalServerErrorException(
|
||||
'The prices are not configured correctly.'
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -199,12 +199,7 @@ export class SubscriptionResolver {
|
||||
});
|
||||
|
||||
if (!session.url) {
|
||||
throw new GraphQLError('Failed to create checkout session', {
|
||||
extensions: {
|
||||
status: HttpStatus[HttpStatus.BAD_GATEWAY],
|
||||
code: HttpStatus.BAD_GATEWAY,
|
||||
},
|
||||
});
|
||||
throw new BadGatewayException('Failed to create checkout session.');
|
||||
}
|
||||
|
||||
return session.url;
|
||||
@@ -263,14 +258,8 @@ export class UserSubscriptionResolver {
|
||||
) {
|
||||
// allow admin to query other user's subscription
|
||||
if (!ctx.isAdminQuery && me.id !== user.id) {
|
||||
throw new GraphQLError(
|
||||
'You are not allowed to access this subscription',
|
||||
{
|
||||
extensions: {
|
||||
status: HttpStatus[HttpStatus.FORBIDDEN],
|
||||
code: HttpStatus.FORBIDDEN,
|
||||
},
|
||||
}
|
||||
throw new ForbiddenException(
|
||||
'You are not allowed to access this subscription.'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -310,12 +299,9 @@ export class UserSubscriptionResolver {
|
||||
@Args('skip', { type: () => Int, nullable: true }) skip?: number
|
||||
) {
|
||||
if (me.id !== user.id) {
|
||||
throw new GraphQLError('You are not allowed to access this invoices', {
|
||||
extensions: {
|
||||
status: HttpStatus[HttpStatus.FORBIDDEN],
|
||||
code: HttpStatus.FORBIDDEN,
|
||||
},
|
||||
});
|
||||
throw new ForbiddenException(
|
||||
'You are not allowed to access this invoices'
|
||||
);
|
||||
}
|
||||
|
||||
return this.db.userInvoice.findMany({
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createAdapter } from '@socket.io/redis-adapter';
|
||||
import { Redis } from 'ioredis';
|
||||
import { Server, ServerOptions } from 'socket.io';
|
||||
|
||||
import { SocketIoAdapter } from '../../fundamentals';
|
||||
import { SocketIoAdapter } from '../../fundamentals/websocket';
|
||||
|
||||
export function createSockerIoAdapterImpl(
|
||||
redis: Redis
|
||||
|
||||
@@ -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';
|
||||
import { Button } from '@affine/component/ui/button';
|
||||
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 { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { ArrowDownBigIcon, GoogleDuotoneIcon } from '@blocksuite/icons';
|
||||
import { GraphQLError } from 'graphql';
|
||||
import { type FC, useState } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
@@ -64,8 +67,7 @@ export const SignIn: FC<AuthPanelProps> = ({
|
||||
const user: GetUserQuery['user'] | null | 0 = await verifyUser({ email })
|
||||
.then(({ user }) => user)
|
||||
.catch(err => {
|
||||
const e = err?.[0];
|
||||
if (e instanceof GraphQLError && e.extensions?.code === 402) {
|
||||
if (findGraphQLError(err, e => e.extensions.code === 402)) {
|
||||
setAuthState('noAccess');
|
||||
return 0;
|
||||
} else {
|
||||
|
||||
@@ -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 './graphql';
|
||||
export * from './schema';
|
||||
@@ -18,5 +19,3 @@ export function getBaseUrl(): string {
|
||||
}
|
||||
|
||||
export const fetcher = gqlFetcherFactory(getBaseUrl() + '/graphql');
|
||||
|
||||
export { GraphQLError } from 'graphql';
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import {
|
||||
deleteBlobMutation,
|
||||
fetchWithTraceReport,
|
||||
findGraphQLError,
|
||||
getBaseUrl,
|
||||
GraphQLError,
|
||||
listBlobsQuery,
|
||||
setBlobMutation,
|
||||
} from '@affine/graphql';
|
||||
import { fetcher } from '@affine/graphql';
|
||||
import { type BlobStorage, BlobStorageOverCapacity } from '@toeverything/infra';
|
||||
import { isArray } from 'lodash-es';
|
||||
|
||||
import { bufferToBlob } from '../utils/buffer-to-blob';
|
||||
|
||||
@@ -43,13 +42,15 @@ export class AffineCloudBlobStorage implements BlobStorage {
|
||||
})
|
||||
.then(res => res.setBlob)
|
||||
.catch(err => {
|
||||
if (isArray(err)) {
|
||||
err.map(e => {
|
||||
if (e instanceof GraphQLError && e.extensions.code === 413) {
|
||||
throw new BlobStorageOverCapacity(e);
|
||||
} else throw e;
|
||||
});
|
||||
const uploadError = findGraphQLError(
|
||||
err,
|
||||
e => e.extensions.code === 413
|
||||
);
|
||||
|
||||
if (uploadError) {
|
||||
throw new BlobStorageOverCapacity(uploadError);
|
||||
}
|
||||
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user