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

@@ -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) {

View File

@@ -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,
},
}
); );
} }

View File

@@ -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,
}, },
}); });

View File

@@ -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);
} }

View File

@@ -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);
} }
} }

View File

@@ -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,
},
}
); );
} }
} }

View File

@@ -0,0 +1 @@
export * from './payment-required';

View File

@@ -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
);
}
}

View File

@@ -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],

View File

@@ -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();
}, },
}); });

View File

@@ -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';

View 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);
}
}
}
}

View File

@@ -1 +1,2 @@
export * from './exception';
export * from './optional-module'; export * from './optional-module';

View File

@@ -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,
});
}
}
}

View File

@@ -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();
}; };

View File

@@ -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({

View File

@@ -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

View 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]);
});

View File

@@ -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 {

View 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;
}
}

View File

@@ -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';

View File

@@ -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;
}); });
} }