Skip to content

Commit 1a5c8f6

Browse files
authored
feat(Feat/error handling): Handled GraphQL errors according to the graphql error guide (#41)
* docs: add graphql status description * chore: remove old stuff * Merge branch 'main' of https://github.com/Ho-s/NestJS-GraphQL-TypeORM-PostgresQL into feat/error-handling * fix: update no-unused-vars rule in ESLint configuration * feat: add GraphQL exception silencer filter * feat: implement base exception handling with customizable messages * feat: replace BadRequestException with CustomConflictException for username conflict * feat: implement custom error handling with GraphQL error formatter and HTTP status plugin * docs: add ref about graphql built in error * refactor: replace UnauthorizedException with CustomUnauthorizedException and update error handling across strategies and services * feat: add GraphQL request validation for content type and handle Not Acceptable responses
1 parent afb9be7 commit 1a5c8f6

19 files changed

+474
-109
lines changed

README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ NestJS boilerplate with TypeORM, GraphQL and PostgreSQL
3232
- [7.1. Protected Queries/Mutation By Role](#71-protected-queriesmutation-by-role)
3333
- [7.2. GraphQL Query To Select and Relations](#72-graphql-query-to-select-and-relations)
3434
- [7.3. Field-Level Permission](#73-field-level-permission)
35+
- [7.4. GraphQL Status Code](#74-graphql-status-code)
3536

3637
- [8. Custom CRUD](#8-custom-crud)
3738

@@ -290,6 +291,11 @@ With this API, if the client request includes the field "something," a `Forbidde
290291

291292
There might be duplicate code when using this guard alongside `other interceptors`(name: `UseRepositoryInterceptor`) in this boilerplate. In such cases, you may need to adjust the code to ensure compatibility.
292293

294+
### 7.4. GraphQL Status Code
295+
296+
Based on the GraphQL status code standard, we write status codes accordingly.
297+
You can see more details [here](./graphql-status-code.md).
298+
293299
## 8. Custom CRUD
294300

295301
To make most of GraphQL's advantage, We created its own api, such as GetMany or GetOne.
@@ -503,8 +509,6 @@ db.public.registerFunction({
503509
- [x] Divide usefactory
504510
- [x] SWC Compiler
505511
- [x] Refresh Token
506-
- [ ] Redis
507-
- [ ] ElasticSearch
508512
- [x] Caching
509513
- [ ] Graphql Subscription
510514
- [x] Remove lodash

error.md

Lines changed: 0 additions & 75 deletions
This file was deleted.

eslint.config.mjs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ export default [
4848
'@typescript-eslint/explicit-function-return-type': 'off',
4949
'@typescript-eslint/explicit-module-boundary-types': 'off',
5050
'@typescript-eslint/no-explicit-any': 'off',
51+
'@typescript-eslint/no-unused-vars': [
52+
'warn',
53+
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
54+
],
5155
},
5256
},
5357
];

graphql-status-code.md

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# GraphQL Response Status Codes
2+
3+
- [HTTP Status Codes](#http-status-codes)
4+
- [✅ 200 OK Responses](#200-ok-responses)
5+
- [Successful Request](#✅-successful-request)
6+
- [Partial Success](#⚠️-partial-success-some-data--some-errors)
7+
- [No Data (e.g., NotFoundErrorException)](#⚠️-no-data-eg-notfounderrorexception)
8+
- [❌ Non-200 Responses](#non-200-responses)
9+
- [Bad Request (Invalid JSON, Syntax Error, etc.)](#❌-bad-request-invalid-json-syntax-error-etc)
10+
- [Authentication/Authorization Failure](#❌-authenticationauthorization-failure)
11+
- [Unsupported Accept Header](#❌-unsupported-accept-header)
12+
- [Internal Server Error](#❌-internal-server-error)
13+
- [Summary Table](#summary-table)
14+
- [References](#references)
15+
16+
## HTTP Status Codes
17+
18+
GraphQL generally follows standard HTTP status code conventions, with some GraphQL-specific nuances.
19+
20+
### 200 OK Responses
21+
22+
#### ✅ Successful Request
23+
24+
- **HTTP Status Code**: `200 OK`
25+
- **Data**: Not `null`
26+
- **Errors**: `N/A`
27+
28+
#### ⚠️ Partial Success (Some Data & Some Errors)
29+
30+
- **HTTP Status Code**: `200 OK`
31+
- **Data**: Not `null`
32+
- **Errors**: `Array<GraphQLError>` (length ≥ 1)
33+
34+
#### ⚠️ No Data (e.g., NotFoundErrorException)
35+
36+
When a GraphQL response includes only errors and no data, the [GraphQL over HTTP specification](https://github.com/graphql/graphql-over-http/blob/main/spec/GraphQLOverHTTP.md#applicationjson) mandates the use of `200 OK`.
37+
38+
- **HTTP Status Code**: `200 OK`
39+
- **Data**: `null`
40+
- **Errors**: `Array<GraphQLError>` (length ≥ 1)
41+
42+
### Non-200(OK) Responses
43+
44+
#### ❌ Bad Request (Invalid JSON, Syntax Error, etc.)
45+
46+
Used when the GraphQL request is malformed or invalid.
47+
48+
- **HTTP Status Code**: `400 Bad Request`
49+
- **Data**: `null`
50+
- **Errors**: `Array<GraphQLError>` (length = 1)
51+
52+
ref: [built-in-error-codes](https://www.apollographql.com/docs/apollo-server/data/errors#built-in-error-codes)
53+
54+
#### ❌ Authentication/Authorization Failure
55+
56+
Used when the request is unauthenticated or the client lacks required permissions.
57+
58+
- **HTTP Status Code**: `401 Unauthorized`, `403 Forbidden`
59+
- **Data**: `null`
60+
- **Errors**: `Array<GraphQLError>` (length = 1)
61+
62+
#### ❌ Unsupported Accept Header
63+
64+
Returned when the request’s `Accept` header does not include `application/graphql-response+json`.
65+
66+
While most GraphQL errors are handled via a custom `formatError`, HTTP-level errors like `406 Not Acceptable` should be handled early (e.g., using middleware and apply globally).
67+
68+
- **HTTP Status Code**: `406 Not Acceptable`
69+
- **Data**: `null`
70+
- **Errors**: `Array<GraphQLError>` (length = 1)
71+
72+
#### ❌ Internal Server Error
73+
74+
Used when an unexpected server-side error or unhandled exception occurs.
75+
76+
- **HTTP Status Code**: `500 Internal Server Error`
77+
- **Data**: `null`
78+
- **Errors**: `Array<GraphQLError>` (length = 1)
79+
80+
## Summary Table
81+
82+
| Scenario | HTTP Status | Data | Errors |
83+
| ----------------------------- | ----------- | ---- | ------ |
84+
| Success | 200 |||
85+
| Partial Success | 200 |||
86+
| No Data | 200 |||
87+
| Invalid Request (Syntax, etc) | 400 |||
88+
| Auth Failure | 401 / 403 |||
89+
| Unsupported Accept Header | 406 |||
90+
| Internal Server Error | 500 |||
91+
92+
## References
93+
94+
This error-handling approach follows the [GraphQL over HTTP specification](https://graphql.github.io/graphql-over-http/draft/), which is currently in draft status and subject to change.
95+
96+
- [GraphQL over HTTP - Status Codes](https://graphql.org/learn/serving-over-http/#status-codes)
97+
- [GraphQL Over HTTP Specification (GitHub)](https://github.com/graphql/graphql-over-http/blob/main/spec/GraphQLOverHTTP.md)
98+
- [GraphQL over HTTP (Draft)](https://graphql.github.io/graphql-over-http/draft/)

src/auth/auth.service.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import { BadRequestException, Injectable } from '@nestjs/common';
1+
import { Injectable } from '@nestjs/common';
22
import { ConfigService } from '@nestjs/config';
33
import { JwtService } from '@nestjs/jwt';
44

55
import * as bcrypt from 'bcrypt';
66

77
import { SignInInput, SignUpInput } from 'src/auth/inputs/auth.input';
8+
import { CustomConflictException } from 'src/common/exceptions';
89
import { EnvironmentVariables } from 'src/common/helper/env.validation';
910
import { UtilService } from 'src/common/util/util.service';
1011
import { User } from 'src/user/entities/user.entity';
@@ -58,12 +59,12 @@ export class AuthService {
5859
}
5960

6061
async signUp(input: SignUpInput): Promise<JwtWithUser> {
61-
const doesExistId = await this.userService.getOne({
62+
const doesExist = await this.userService.getOne({
6263
where: { username: input.username },
6364
});
6465

65-
if (doesExistId) {
66-
throw new BadRequestException('Username already exists');
66+
if (doesExist) {
67+
throw new CustomConflictException({ property: 'username' });
6768
}
6869

6970
const user = await this.userService.create({ ...input, role: 'user' });

src/auth/strategies/jwt-refresh.strategy.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import { Injectable, UnauthorizedException } from '@nestjs/common';
1+
import { Injectable } from '@nestjs/common';
22
import { ConfigService } from '@nestjs/config';
33
import { PassportStrategy } from '@nestjs/passport';
44

55
import { ExtractJwt, Strategy, VerifiedCallback } from 'passport-jwt';
66

7+
import { CustomUnauthorizedException } from 'src/common/exceptions';
78
import { EnvironmentVariables } from 'src/common/helper/env.validation';
89

910
import { AuthService } from '../auth.service';
@@ -35,7 +36,7 @@ export class JwtRefreshStrategy extends PassportStrategy(
3536

3637
done(null, userData);
3738
} catch (err) {
38-
throw new UnauthorizedException('Error', err.message);
39+
throw new CustomUnauthorizedException({ message: err.message });
3940
}
4041
}
4142
}

src/auth/strategies/jwt.strategy.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import { Injectable, UnauthorizedException } from '@nestjs/common';
1+
import { Injectable } from '@nestjs/common';
22
import { ConfigService } from '@nestjs/config';
33
import { PassportStrategy } from '@nestjs/passport';
44

55
import { ExtractJwt, Strategy, VerifiedCallback } from 'passport-jwt';
66

7+
import { CustomUnauthorizedException } from 'src/common/exceptions';
78
import { EnvironmentVariables } from 'src/common/helper/env.validation';
89

910
import { UserService } from '../../user/user.service';
@@ -29,7 +30,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
2930

3031
done(null, userData);
3132
} catch (err) {
32-
throw new UnauthorizedException('Error', err.message);
33+
throw new CustomUnauthorizedException({ message: err.message });
3334
}
3435
}
3536
}

src/auth/strategies/local.strategy.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
import { Injectable, UnauthorizedException } from '@nestjs/common';
1+
import { Injectable } from '@nestjs/common';
22
import { PassportStrategy } from '@nestjs/passport';
33

44
import { Strategy } from 'passport-local';
55

6+
import { CustomUnauthorizedException } from 'src/common/exceptions';
7+
68
import { AuthService } from '../auth.service';
79
import { SignInInput } from '../inputs/auth.input';
810

@@ -18,7 +20,7 @@ export class LocalStrategy extends PassportStrategy(Strategy, 'local') {
1820
const user = this.authService.validateUser({ username, password });
1921

2022
if (!user) {
21-
throw new UnauthorizedException();
23+
throw new CustomUnauthorizedException();
2224
}
2325

2426
return user;

src/common/config/graphql-config.service.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { ApolloDriverConfig } from '@nestjs/apollo';
2-
import { Injectable } from '@nestjs/common';
2+
import { Injectable, Logger } from '@nestjs/common';
33
import { ConfigService } from '@nestjs/config';
44
import { GqlOptionsFactory } from '@nestjs/graphql';
55

@@ -11,7 +11,7 @@ import GraphQLJSON from 'graphql-type-json';
1111
import { join } from 'path';
1212
import { cwd } from 'process';
1313

14-
import { formatError } from '../format/graphql-error.format';
14+
import { httpStatusPlugin } from '../exceptions/exception.plugin';
1515

1616
@Injectable()
1717
export class GraphqlConfigService
@@ -29,14 +29,14 @@ export class GraphqlConfigService
2929
sortSchema: true,
3030
playground: false,
3131
plugins: [
32+
httpStatusPlugin,
3233
this.configService.get('NODE_ENV') === 'production'
3334
? ApolloServerPluginLandingPageProductionDefault()
3435
: ApolloServerPluginLandingPageLocalDefault(),
3536
],
3637

3738
context: ({ req }) => ({ req }),
3839
cache: 'bounded',
39-
formatError,
4040
};
4141
}
4242
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { ArgumentsHost, Catch, ExceptionFilter } from '@nestjs/common';
2+
import { GqlArgumentsHost } from '@nestjs/graphql';
3+
4+
@Catch()
5+
export class GraphQLExceptionSilencer implements ExceptionFilter {
6+
catch(exception: unknown, host: ArgumentsHost) {
7+
GqlArgumentsHost.create(host);
8+
9+
throw exception;
10+
}
11+
}

0 commit comments

Comments
 (0)