Skip to content

Commit 36e21f1

Browse files
feat(graphql): graphQL custom field complexity and validationRules (#9955)
### What? Adds the ability to set custom validation rules on the root `graphQL` config property and the ability to define custom complexity on relationship, join and upload type fields. ### Why? **Validation Rules** These give you the option to add your own validation rules. For example, you may want to prevent introspection queries in production. You can now do that with the following: ```ts import { GraphQL } from '@payloadcms/graphql/types' import { buildConfig } from 'payload' export default buildConfig({ // ... graphQL: { validationRules: (args) => [ NoProductionIntrospection ] }, // ... }) const NoProductionIntrospection: GraphQL.ValidationRule = (context) => ({ Field(node) { if (process.env.NODE_ENV === 'production') { if (node.name.value === '__schema' || node.name.value === '__type') { context.reportError( new GraphQL.GraphQLError( 'GraphQL introspection is not allowed, but the query contained __schema or __type', { nodes: [node] } ) ); } } } }) ``` **Custom field complexity** You can now increase the complexity of a field, this will help users from running queries that are too expensive. A higher number will make the `maxComplexity` trigger sooner. ```ts const fieldWithComplexity = { name: 'authors', type: 'relationship', relationship: 'authors', graphQL: { complexity: 100, // highlight-line } } ```
1 parent c167365 commit 36e21f1

File tree

16 files changed

+2293
-9
lines changed

16 files changed

+2293
-9
lines changed

docs/fields/join.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ powerful Admin UI.
136136
| **`admin`** | Admin-specific configuration. [More details](#admin-config-options). |
137137
| **`custom`** | Extension point for adding custom data (e.g. for plugins). |
138138
| **`typescriptSchema`** | Override field type generation with providing a JSON schema. |
139+
| **`graphQL`** | Custom graphQL configuration for the field. [More details](/docs/graphql/overview#field-complexity) |
139140

140141
_\* An asterisk denotes that a property is required._
141142

docs/fields/relationship.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ export const MyRelationshipField: Field = {
6161
| **`custom`** | Extension point for adding custom data (e.g. for plugins) |
6262
| **`typescriptSchema`** | Override field type generation with providing a JSON schema |
6363
| **`virtual`** | Provide `true` to disable field in the database. See [Virtual Fields](https://payloadcms.com/blog/learn-how-virtual-fields-can-help-solve-common-cms-challenges) |
64+
| **`graphQL`** | Custom graphQL configuration for the field. [More details](/docs/graphql/overview#field-complexity) |
6465

6566
_\* An asterisk denotes that a property is required._
6667

docs/fields/upload.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ export const MyUploadField: Field = {
6868
| **`custom`** | Extension point for adding custom data (e.g. for plugins) |
6969
| **`typescriptSchema`** | Override field type generation with providing a JSON schema |
7070
| **`virtual`** | Provide `true` to disable field in the database. See [Virtual Fields](https://payloadcms.com/blog/learn-how-virtual-fields-can-help-solve-common-cms-challenges) |
71+
| **`graphQL`** | Custom graphQL configuration for the field. [More details](/docs/graphql/overview#field-complexity) |
7172

7273
_\* An asterisk denotes that a property is required._
7374

docs/graphql/overview.mdx

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ At the top of your Payload Config you can define all the options to manage Graph
2323
| `maxComplexity` | A number used to set the maximum allowed complexity allowed by requests [More](/docs/graphql/overview#query-complexity-limits) |
2424
| `disablePlaygroundInProduction` | A boolean that if false will enable the GraphQL playground, defaults to true. [More](/docs/graphql/overview#graphql-playground) |
2525
| `disable` | A boolean that if true will disable the GraphQL entirely, defaults to false. |
26+
| `validationRules` | A function that takes the ExecutionArgs and returns an array of ValidationRules. |
2627

2728
## Collections
2829

@@ -124,6 +125,55 @@ You can even log in using the `login[collection-singular-label-here]` mutation t
124125
see a ton of detail about how GraphQL operates within Payload.
125126
</Banner>
126127

128+
## Custom Validation Rules
129+
130+
You can add custom validation rules to your GraphQL API by defining a `validationRules` function in your Payload Config. This function should return an array of [Validation Rules](https://graphql.org/graphql-js/validation/#validation-rules) that will be applied to all incoming queries and mutations.
131+
132+
```ts
133+
import { GraphQL } from '@payloadcms/graphql/types'
134+
import { buildConfig } from 'payload'
135+
136+
export default buildConfig({
137+
// ...
138+
graphQL: {
139+
validationRules: (args) => [
140+
NoProductionIntrospection
141+
]
142+
},
143+
// ...
144+
})
145+
146+
const NoProductionIntrospection: GraphQL.ValidationRule = (context) => ({
147+
Field(node) {
148+
if (process.env.NODE_ENV === 'production') {
149+
if (node.name.value === '__schema' || node.name.value === '__type') {
150+
context.reportError(
151+
new GraphQL.GraphQLError(
152+
'GraphQL introspection is not allowed, but the query contained __schema or __type',
153+
{ nodes: [node] }
154+
)
155+
);
156+
}
157+
}
158+
}
159+
})
160+
```
161+
127162
## Query complexity limits
128163

129164
Payload comes with a built-in query complexity limiter to prevent bad people from trying to slow down your server by running massive queries. To learn more, [click here](/docs/production/preventing-abuse#limiting-graphql-complexity).
165+
166+
## Field complexity
167+
168+
You can define custom complexity for `relationship`, `upload` and `join` type fields. This is useful if you want to assign a higher complexity to a field that is more expensive to resolve. This can help prevent users from running queries that are too complex.
169+
170+
```ts
171+
const fieldWithComplexity = {
172+
name: 'authors',
173+
type: 'relationship',
174+
relationship: 'authors',
175+
graphQL: {
176+
complexity: 100, // highlight-line
177+
}
178+
}
179+
```

packages/graphql/src/index.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -98,14 +98,12 @@ export function configToSchema(config: SanitizedConfig): {
9898
const query = new GraphQL.GraphQLObjectType(graphqlResult.Query)
9999
const mutation = new GraphQL.GraphQLObjectType(graphqlResult.Mutation)
100100

101-
const schemaToCreate = {
101+
const schema = new GraphQL.GraphQLSchema({
102102
mutation,
103103
query,
104-
}
105-
106-
const schema = new GraphQL.GraphQLSchema(schemaToCreate)
104+
})
107105

108-
const validationRules = (args) => [
106+
const validationRules = (args): GraphQL.ValidationRule[] => [
109107
createComplexityRule({
110108
estimators: [
111109
fieldExtensionsEstimator(),
@@ -115,6 +113,9 @@ export function configToSchema(config: SanitizedConfig): {
115113
variables: args.variableValues,
116114
// onComplete: (complexity) => { console.log('Query Complexity:', complexity); },
117115
}),
116+
...(typeof config?.graphQL?.validationRules === 'function'
117+
? config.graphQL.validationRules(args)
118+
: []),
118119
]
119120

120121
return {

packages/graphql/src/schema/buildObjectType.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,10 @@ export function buildObjectType({
245245
type: graphqlResult.collections[field.collection].graphQL.whereInputType,
246246
},
247247
},
248-
extensions: { complexity: 10 },
248+
extensions: {
249+
complexity:
250+
typeof field?.graphQL?.complexity === 'number' ? field.graphQL.complexity : 10,
251+
},
249252
async resolve(parent, args, context: Context) {
250253
const { collection } = field
251254
const { limit, sort, where } = args
@@ -416,7 +419,10 @@ export function buildObjectType({
416419
forceNullable,
417420
),
418421
args: relationshipArgs,
419-
extensions: { complexity: 10 },
422+
extensions: {
423+
complexity:
424+
typeof field?.graphQL?.complexity === 'number' ? field.graphQL.complexity : 10,
425+
},
420426
async resolve(parent, args, context: Context) {
421427
const value = parent[field.name]
422428
const locale = args.locale || context.req.locale
@@ -768,7 +774,10 @@ export function buildObjectType({
768774
forceNullable,
769775
),
770776
args: relationshipArgs,
771-
extensions: { complexity: 10 },
777+
extensions: {
778+
complexity:
779+
typeof field?.graphQL?.complexity === 'number' ? field.graphQL.complexity : 10,
780+
},
772781
async resolve(parent, args, context: Context) {
773782
const value = parent[field.name]
774783
const locale = args.locale || context.req.locale

packages/payload/src/config/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -950,6 +950,12 @@ export type Config = {
950950
* Filepath to write the generated schema to
951951
*/
952952
schemaOutputFile?: string
953+
/**
954+
* Function that returns an array of validation rules to apply to the GraphQL schema
955+
*
956+
* @see https://payloadcms.com/docs/graphql/overview#custom-validation-rules
957+
*/
958+
validationRules?: (args: GraphQL.ExecutionArgs) => GraphQL.ValidationRule[]
953959
}
954960
/**
955961
* Tap into Payload-wide hooks.

packages/payload/src/fields/config/client.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export type ServerOnlyFieldProperties =
3232
| 'editor' // This is a `richText` only property
3333
| 'enumName' // can be a function
3434
| 'filterOptions' // This is a `relationship` and `upload` only property
35+
| 'graphQL'
3536
| 'label'
3637
| 'typescriptSchema'
3738
| 'validate'
@@ -53,6 +54,7 @@ const serverOnlyFieldProperties: Partial<ServerOnlyFieldProperties>[] = [
5354
'typescriptSchema',
5455
'dbName', // can be a function
5556
'enumName', // can be a function
57+
'graphQL', // client does not need graphQL
5658
// the following props are handled separately (see below):
5759
// `label`
5860
// `fields`

packages/payload/src/fields/config/types.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,17 @@ export type OptionObject = {
370370

371371
export type Option = OptionObject | string
372372

373+
export type FieldGraphQLType = {
374+
graphQL?: {
375+
/**
376+
* Complexity for the query. This is used to limit the complexity of the join query.
377+
*
378+
* @default 10
379+
*/
380+
complexity?: number
381+
}
382+
}
383+
373384
export interface FieldBase {
374385
/**
375386
* Do not set this property manually. This is set to true during sanitization, to avoid
@@ -844,6 +855,7 @@ type SharedUploadProperties = {
844855
validate?: UploadFieldSingleValidation
845856
}
846857
) &
858+
FieldGraphQLType &
847859
Omit<FieldBase, 'validate'>
848860

849861
type SharedUploadPropertiesClient = FieldBaseClient &
@@ -1023,6 +1035,7 @@ type SharedRelationshipProperties = {
10231035
validate?: RelationshipFieldSingleValidation
10241036
}
10251037
) &
1038+
FieldGraphQLType &
10261039
Omit<FieldBase, 'validate'>
10271040

10281041
type SharedRelationshipPropertiesClient = FieldBaseClient &
@@ -1405,7 +1418,8 @@ export type JoinField = {
14051418
type: 'join'
14061419
validate?: never
14071420
where?: Where
1408-
} & FieldBase
1421+
} & FieldBase &
1422+
FieldGraphQLType
14091423

14101424
export type JoinFieldClient = {
14111425
admin?: AdminClient & Pick<JoinField['admin'], 'allowCreate' | 'disableBulkEdit' | 'readOnly'>

test/graphql/config.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { GraphQL } from '@payloadcms/graphql/types'
2+
import { fileURLToPath } from 'node:url'
3+
import path from 'path'
4+
5+
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
6+
import { devUser } from '../credentials.js'
7+
8+
const filename = fileURLToPath(import.meta.url)
9+
const dirname = path.dirname(filename)
10+
11+
export default buildConfigWithDefaults({
12+
// ...extend config here
13+
collections: [
14+
{
15+
slug: 'posts',
16+
fields: [
17+
{
18+
name: 'title',
19+
label: 'Title',
20+
type: 'text',
21+
},
22+
{
23+
type: 'relationship',
24+
relationTo: 'posts',
25+
name: 'relationToSelf',
26+
graphQL: {
27+
complexity: 801,
28+
},
29+
},
30+
],
31+
},
32+
],
33+
admin: {
34+
importMap: {
35+
baseDir: path.resolve(dirname),
36+
},
37+
},
38+
onInit: async (payload) => {
39+
await payload.create({
40+
collection: 'users',
41+
data: {
42+
email: devUser.email,
43+
password: devUser.password,
44+
},
45+
})
46+
},
47+
typescript: {
48+
outputFile: path.resolve(dirname, 'payload-types.ts'),
49+
},
50+
graphQL: {
51+
maxComplexity: 800,
52+
validationRules: () => [NoIntrospection],
53+
},
54+
})
55+
56+
const NoIntrospection: GraphQL.ValidationRule = (context) => ({
57+
Field(node) {
58+
if (node.name.value === '__schema' || node.name.value === '__type') {
59+
context.reportError(
60+
new GraphQL.GraphQLError(
61+
'GraphQL introspection is not allowed, but the query contained __schema or __type',
62+
{ nodes: [node] },
63+
),
64+
)
65+
}
66+
},
67+
})

0 commit comments

Comments
 (0)