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
  }
}
```
This commit is contained in:
Jarrod Flesch
2024-12-13 15:03:57 -05:00
committed by GitHub
parent c1673652a8
commit 36e21f182a
16 changed files with 2293 additions and 9 deletions

View File

@@ -136,6 +136,7 @@ powerful Admin UI.
| **`admin`** | Admin-specific configuration. [More details](#admin-config-options). | | **`admin`** | Admin-specific configuration. [More details](#admin-config-options). |
| **`custom`** | Extension point for adding custom data (e.g. for plugins). | | **`custom`** | Extension point for adding custom data (e.g. for plugins). |
| **`typescriptSchema`** | Override field type generation with providing a JSON schema. | | **`typescriptSchema`** | Override field type generation with providing a JSON schema. |
| **`graphQL`** | Custom graphQL configuration for the field. [More details](/docs/graphql/overview#field-complexity) |
_\* An asterisk denotes that a property is required._ _\* An asterisk denotes that a property is required._

View File

@@ -61,6 +61,7 @@ export const MyRelationshipField: Field = {
| **`custom`** | Extension point for adding custom data (e.g. for plugins) | | **`custom`** | Extension point for adding custom data (e.g. for plugins) |
| **`typescriptSchema`** | Override field type generation with providing a JSON schema | | **`typescriptSchema`** | Override field type generation with providing a JSON schema |
| **`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) | | **`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) |
| **`graphQL`** | Custom graphQL configuration for the field. [More details](/docs/graphql/overview#field-complexity) |
_\* An asterisk denotes that a property is required._ _\* An asterisk denotes that a property is required._

View File

@@ -68,6 +68,7 @@ export const MyUploadField: Field = {
| **`custom`** | Extension point for adding custom data (e.g. for plugins) | | **`custom`** | Extension point for adding custom data (e.g. for plugins) |
| **`typescriptSchema`** | Override field type generation with providing a JSON schema | | **`typescriptSchema`** | Override field type generation with providing a JSON schema |
| **`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) | | **`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) |
| **`graphQL`** | Custom graphQL configuration for the field. [More details](/docs/graphql/overview#field-complexity) |
_\* An asterisk denotes that a property is required._ _\* An asterisk denotes that a property is required._

View File

@@ -23,6 +23,7 @@ At the top of your Payload Config you can define all the options to manage Graph
| `maxComplexity` | A number used to set the maximum allowed complexity allowed by requests [More](/docs/graphql/overview#query-complexity-limits) | | `maxComplexity` | A number used to set the maximum allowed complexity allowed by requests [More](/docs/graphql/overview#query-complexity-limits) |
| `disablePlaygroundInProduction` | A boolean that if false will enable the GraphQL playground, defaults to true. [More](/docs/graphql/overview#graphql-playground) | | `disablePlaygroundInProduction` | A boolean that if false will enable the GraphQL playground, defaults to true. [More](/docs/graphql/overview#graphql-playground) |
| `disable` | A boolean that if true will disable the GraphQL entirely, defaults to false. | | `disable` | A boolean that if true will disable the GraphQL entirely, defaults to false. |
| `validationRules` | A function that takes the ExecutionArgs and returns an array of ValidationRules. |
## Collections ## Collections
@@ -124,6 +125,55 @@ You can even log in using the `login[collection-singular-label-here]` mutation t
see a ton of detail about how GraphQL operates within Payload. see a ton of detail about how GraphQL operates within Payload.
</Banner> </Banner>
## Custom Validation Rules
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.
```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] }
)
);
}
}
}
})
```
## Query complexity limits ## Query complexity limits
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). 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).
## Field complexity
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.
```ts
const fieldWithComplexity = {
name: 'authors',
type: 'relationship',
relationship: 'authors',
graphQL: {
complexity: 100, // highlight-line
}
}
```

View File

@@ -98,14 +98,12 @@ export function configToSchema(config: SanitizedConfig): {
const query = new GraphQL.GraphQLObjectType(graphqlResult.Query) const query = new GraphQL.GraphQLObjectType(graphqlResult.Query)
const mutation = new GraphQL.GraphQLObjectType(graphqlResult.Mutation) const mutation = new GraphQL.GraphQLObjectType(graphqlResult.Mutation)
const schemaToCreate = { const schema = new GraphQL.GraphQLSchema({
mutation, mutation,
query, query,
} })
const schema = new GraphQL.GraphQLSchema(schemaToCreate) const validationRules = (args): GraphQL.ValidationRule[] => [
const validationRules = (args) => [
createComplexityRule({ createComplexityRule({
estimators: [ estimators: [
fieldExtensionsEstimator(), fieldExtensionsEstimator(),
@@ -115,6 +113,9 @@ export function configToSchema(config: SanitizedConfig): {
variables: args.variableValues, variables: args.variableValues,
// onComplete: (complexity) => { console.log('Query Complexity:', complexity); }, // onComplete: (complexity) => { console.log('Query Complexity:', complexity); },
}), }),
...(typeof config?.graphQL?.validationRules === 'function'
? config.graphQL.validationRules(args)
: []),
] ]
return { return {

View File

@@ -245,7 +245,10 @@ export function buildObjectType({
type: graphqlResult.collections[field.collection].graphQL.whereInputType, type: graphqlResult.collections[field.collection].graphQL.whereInputType,
}, },
}, },
extensions: { complexity: 10 }, extensions: {
complexity:
typeof field?.graphQL?.complexity === 'number' ? field.graphQL.complexity : 10,
},
async resolve(parent, args, context: Context) { async resolve(parent, args, context: Context) {
const { collection } = field const { collection } = field
const { limit, sort, where } = args const { limit, sort, where } = args
@@ -416,7 +419,10 @@ export function buildObjectType({
forceNullable, forceNullable,
), ),
args: relationshipArgs, args: relationshipArgs,
extensions: { complexity: 10 }, extensions: {
complexity:
typeof field?.graphQL?.complexity === 'number' ? field.graphQL.complexity : 10,
},
async resolve(parent, args, context: Context) { async resolve(parent, args, context: Context) {
const value = parent[field.name] const value = parent[field.name]
const locale = args.locale || context.req.locale const locale = args.locale || context.req.locale
@@ -768,7 +774,10 @@ export function buildObjectType({
forceNullable, forceNullable,
), ),
args: relationshipArgs, args: relationshipArgs,
extensions: { complexity: 10 }, extensions: {
complexity:
typeof field?.graphQL?.complexity === 'number' ? field.graphQL.complexity : 10,
},
async resolve(parent, args, context: Context) { async resolve(parent, args, context: Context) {
const value = parent[field.name] const value = parent[field.name]
const locale = args.locale || context.req.locale const locale = args.locale || context.req.locale

View File

@@ -950,6 +950,12 @@ export type Config = {
* Filepath to write the generated schema to * Filepath to write the generated schema to
*/ */
schemaOutputFile?: string schemaOutputFile?: string
/**
* Function that returns an array of validation rules to apply to the GraphQL schema
*
* @see https://payloadcms.com/docs/graphql/overview#custom-validation-rules
*/
validationRules?: (args: GraphQL.ExecutionArgs) => GraphQL.ValidationRule[]
} }
/** /**
* Tap into Payload-wide hooks. * Tap into Payload-wide hooks.

View File

@@ -32,6 +32,7 @@ export type ServerOnlyFieldProperties =
| 'editor' // This is a `richText` only property | 'editor' // This is a `richText` only property
| 'enumName' // can be a function | 'enumName' // can be a function
| 'filterOptions' // This is a `relationship` and `upload` only property | 'filterOptions' // This is a `relationship` and `upload` only property
| 'graphQL'
| 'label' | 'label'
| 'typescriptSchema' | 'typescriptSchema'
| 'validate' | 'validate'
@@ -53,6 +54,7 @@ const serverOnlyFieldProperties: Partial<ServerOnlyFieldProperties>[] = [
'typescriptSchema', 'typescriptSchema',
'dbName', // can be a function 'dbName', // can be a function
'enumName', // can be a function 'enumName', // can be a function
'graphQL', // client does not need graphQL
// the following props are handled separately (see below): // the following props are handled separately (see below):
// `label` // `label`
// `fields` // `fields`

View File

@@ -370,6 +370,17 @@ export type OptionObject = {
export type Option = OptionObject | string export type Option = OptionObject | string
export type FieldGraphQLType = {
graphQL?: {
/**
* Complexity for the query. This is used to limit the complexity of the join query.
*
* @default 10
*/
complexity?: number
}
}
export interface FieldBase { export interface FieldBase {
/** /**
* Do not set this property manually. This is set to true during sanitization, to avoid * Do not set this property manually. This is set to true during sanitization, to avoid
@@ -844,6 +855,7 @@ type SharedUploadProperties = {
validate?: UploadFieldSingleValidation validate?: UploadFieldSingleValidation
} }
) & ) &
FieldGraphQLType &
Omit<FieldBase, 'validate'> Omit<FieldBase, 'validate'>
type SharedUploadPropertiesClient = FieldBaseClient & type SharedUploadPropertiesClient = FieldBaseClient &
@@ -1023,6 +1035,7 @@ type SharedRelationshipProperties = {
validate?: RelationshipFieldSingleValidation validate?: RelationshipFieldSingleValidation
} }
) & ) &
FieldGraphQLType &
Omit<FieldBase, 'validate'> Omit<FieldBase, 'validate'>
type SharedRelationshipPropertiesClient = FieldBaseClient & type SharedRelationshipPropertiesClient = FieldBaseClient &
@@ -1405,7 +1418,8 @@ export type JoinField = {
type: 'join' type: 'join'
validate?: never validate?: never
where?: Where where?: Where
} & FieldBase } & FieldBase &
FieldGraphQLType
export type JoinFieldClient = { export type JoinFieldClient = {
admin?: AdminClient & Pick<JoinField['admin'], 'allowCreate' | 'disableBulkEdit' | 'readOnly'> admin?: AdminClient & Pick<JoinField['admin'], 'allowCreate' | 'disableBulkEdit' | 'readOnly'>

67
test/graphql/config.ts Normal file
View File

@@ -0,0 +1,67 @@
import { GraphQL } from '@payloadcms/graphql/types'
import { fileURLToPath } from 'node:url'
import path from 'path'
import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js'
import { devUser } from '../credentials.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
export default buildConfigWithDefaults({
// ...extend config here
collections: [
{
slug: 'posts',
fields: [
{
name: 'title',
label: 'Title',
type: 'text',
},
{
type: 'relationship',
relationTo: 'posts',
name: 'relationToSelf',
graphQL: {
complexity: 801,
},
},
],
},
],
admin: {
importMap: {
baseDir: path.resolve(dirname),
},
},
onInit: async (payload) => {
await payload.create({
collection: 'users',
data: {
email: devUser.email,
password: devUser.password,
},
})
},
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
graphQL: {
maxComplexity: 800,
validationRules: () => [NoIntrospection],
},
})
const NoIntrospection: GraphQL.ValidationRule = (context) => ({
Field(node) {
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] },
),
)
}
},
})

View File

@@ -0,0 +1,19 @@
import { rootParserOptions } from '../../eslint.config.js'
import testEslintConfig from '../eslint.config.js'
/** @typedef {import('eslint').Linter.Config} Config */
/** @type {Config[]} */
export const index = [
...testEslintConfig,
{
languageOptions: {
parserOptions: {
...rootParserOptions,
tsconfigRootDir: import.meta.dirname,
},
},
},
]
export default index

84
test/graphql/int.spec.ts Normal file
View File

@@ -0,0 +1,84 @@
import type { Payload } from 'payload'
import path from 'path'
import { fileURLToPath } from 'url'
import type { NextRESTClient } from '../helpers/NextRESTClient.js'
import { idToString } from '../helpers/idToString.js'
import { initPayloadInt } from '../helpers/initPayloadInt.js'
let payload: Payload
let restClient: NextRESTClient
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
describe('graphql', () => {
beforeAll(async () => {
;({ payload, restClient } = await initPayloadInt(dirname))
})
afterAll(async () => {
if (typeof payload.db.destroy === 'function') {
await payload.db.destroy()
}
})
describe('graphql', () => {
it('should not be able to query introspection', async () => {
const query = `query {
__schema {
queryType {
name
}
}
}`
const response = await restClient
.GRAPHQL_POST({
body: JSON.stringify({ query }),
})
.then((res) => res.json())
expect(response.errors[0].message).toMatch(
'GraphQL introspection is not allowed, but the query contained __schema or __type',
)
})
it('should respect maxComplexity', async () => {
const post = await payload.create({
collection: 'posts',
data: {
title: 'example post',
},
})
await payload.update({
collection: 'posts',
id: post.id,
data: {
relatedToSelf: post.id,
},
})
const query = `query {
Post(id: ${idToString(post.id, payload)}) {
title
relationToSelf {
id
}
}
}`
const response = await restClient
.GRAPHQL_POST({
body: JSON.stringify({ query }),
})
.then((res) => res.json())
expect(response.errors[0].message).toMatch(
'The query exceeds the maximum complexity of 800. Actual complexity is 804',
)
})
})
})

View File

@@ -0,0 +1,214 @@
/* tslint:disable */
/* eslint-disable */
/**
* This file was automatically generated by Payload.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* and re-run `payload generate:types` to regenerate this file.
*/
export interface Config {
auth: {
users: UserAuthOperations;
};
collections: {
posts: Post;
users: User;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
};
collectionsJoins: {};
collectionsSelect: {
posts: PostsSelect<false> | PostsSelect<true>;
users: UsersSelect<false> | UsersSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
};
db: {
defaultIDType: string;
};
globals: {};
globalsSelect: {};
locale: null;
user: User & {
collection: 'users';
};
jobs: {
tasks: unknown;
workflows: unknown;
};
}
export interface UserAuthOperations {
forgotPassword: {
email: string;
password: string;
};
login: {
email: string;
password: string;
};
registerFirstUser: {
email: string;
password: string;
};
unlock: {
email: string;
password: string;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "posts".
*/
export interface Post {
id: string;
title?: string | null;
relationToSelf?: (string | null) | Post;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".
*/
export interface User {
id: string;
updatedAt: string;
createdAt: string;
email: string;
resetPasswordToken?: string | null;
resetPasswordExpiration?: string | null;
salt?: string | null;
hash?: string | null;
loginAttempts?: number | null;
lockUntil?: string | null;
password?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents".
*/
export interface PayloadLockedDocument {
id: string;
document?:
| ({
relationTo: 'posts';
value: string | Post;
} | null)
| ({
relationTo: 'users';
value: string | User;
} | null);
globalSlug?: string | null;
user: {
relationTo: 'users';
value: string | User;
};
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences".
*/
export interface PayloadPreference {
id: string;
user: {
relationTo: 'users';
value: string | User;
};
key?: string | null;
value?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations".
*/
export interface PayloadMigration {
id: string;
name?: string | null;
batch?: number | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "posts_select".
*/
export interface PostsSelect<T extends boolean = true> {
title?: T;
relationToSelf?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users_select".
*/
export interface UsersSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
email?: T;
resetPasswordToken?: T;
resetPasswordExpiration?: T;
salt?: T;
hash?: T;
loginAttempts?: T;
lockUntil?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents_select".
*/
export interface PayloadLockedDocumentsSelect<T extends boolean = true> {
document?: T;
globalSlug?: T;
user?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences_select".
*/
export interface PayloadPreferencesSelect<T extends boolean = true> {
user?: T;
key?: T;
value?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations_select".
*/
export interface PayloadMigrationsSelect<T extends boolean = true> {
name?: T;
batch?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "auth".
*/
export interface Auth {
[k: string]: unknown;
}
declare module 'payload' {
// @ts-ignore
export interface GeneratedTypes extends Config {}
}

1799
test/graphql/schema.graphql Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,13 @@
{
// extend your base config to share compilerOptions, etc
//"extends": "./tsconfig.json",
"compilerOptions": {
// ensure that nobody can accidentally use this config for a build
"noEmit": true
},
"include": [
// whatever paths you intend to lint
"./**/*.ts",
"./**/*.tsx"
]
}

View File

@@ -0,0 +1,3 @@
{
"extends": "../tsconfig.json"
}