diff --git a/packages/plugins/root-level-limitation/__tests__/root-level-limitation.spec.ts b/packages/plugins/root-level-limitation/__tests__/root-level-limitation.spec.ts new file mode 100644 index 0000000000..be922710a7 --- /dev/null +++ b/packages/plugins/root-level-limitation/__tests__/root-level-limitation.spec.ts @@ -0,0 +1,114 @@ +import { createSchema, createYoga } from 'graphql-yoga'; +import { rootLevelQueryLimit } from '../src/index.js'; + +describe('root-level-limitation', () => { + const schema = createSchema({ + typeDefs: /* GraphQL */ ` + type Query { + topProducts: GetTopProducts + topBooks: GetTopBooks + } + type GetTopBooks { + id: Int + } + type GetTopProducts { + id: Int + } + `, + }); + + const yoga = createYoga({ + schema, + plugins: [rootLevelQueryLimit({ maxRootLevelFields: 1 })], + maskedErrors: false, + }); + + it('should not allow requests with max root level query', async () => { + const res = await yoga.fetch('http://yoga/graphql', { + method: 'POST', + body: JSON.stringify({ + query: /* GraphQL */ ` + { + topBooks { + id + } + topProducts { + id + } + } + `, + }), + headers: { + 'Content-Type': 'application/json', + }, + }); + + expect(res.status).toBe(400); + }); + + it('should allow requests with max root level query', async () => { + const res = await yoga.fetch('http://yoga/graphql', { + method: 'POST', + body: JSON.stringify({ + query: /* GraphQL */ ` + { + topProducts { + id + } + } + `, + }), + headers: { + 'Content-Type': 'application/json', + }, + }); + expect(res.status).toBe(200); + }); + + it('should not allow requests with max root level query and nested fragments', async () => { + const res = await yoga.fetch('http://yoga/graphql', { + method: 'POST', + body: JSON.stringify({ + query: /* GraphQL */ ` + fragment QueryFragment on Query { + topBooks { + id + } + topProducts { + id + } + } + { + ...QueryFragment + } + `, + }), + headers: { + 'Content-Type': 'application/json', + }, + }); + }) + + it('should allow requests with max root level query in comments', async () => { + const res = await yoga.fetch('http://yoga/graphql', { + method: 'POST', + body: JSON.stringify({ + query: /* GraphQL */ ` + { + # topBooks { + # id + # } + topProducts { + id + } + } + `, + }), + headers: { + 'Content-Type': 'application/json', + }, + }); + + expect(res.status).toBe(200); + }) +}); diff --git a/packages/plugins/root-level-limitation/package.json b/packages/plugins/root-level-limitation/package.json new file mode 100644 index 0000000000..8b0e62d6e4 --- /dev/null +++ b/packages/plugins/root-level-limitation/package.json @@ -0,0 +1,59 @@ +{ + "name": "@graphql-yoga/plugin-root-level-limitation", + "version": "1.0.0", + "type": "module", + "description": "Apollo's federated check max root values plugin for GraphQL Yoga.", + "repository": { + "type": "git", + "url": "https://github.com/dotansimha/graphql-yoga.git", + "directory": "packages/plugins/root-level-limitation" + }, + "author": "Saeed Akasteh ", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "main": "dist/cjs/index.js", + "module": "dist/esm/index.js", + "exports": { + ".": { + "require": { + "types": "./dist/typings/index.d.cts", + "default": "./dist/cjs/index.js" + }, + "import": { + "types": "./dist/typings/index.d.ts", + "default": "./dist/esm/index.js" + }, + "default": { + "types": "./dist/typings/index.d.ts", + "default": "./dist/esm/index.js" + } + }, + "./package.json": "./package.json" + }, + "typings": "dist/typings/index.d.ts", + "scripts": { + "check": "tsc --pretty --noEmit" + }, + "peerDependencies": { + "@graphql-tools/utils": "^10.1.0", + "@envelop/core": "^5.0.0", + "graphql": "^15.2.0 || ^16.0.0" + }, + "dependencies": { + "tslib": "^2.5.2" + }, + "devDependencies": { + "@envelop/core": "^5.0.0", + "graphql": "^16.6.0", + "graphql-yoga": "5.3.0" + }, + "publishConfig": { + "directory": "dist", + "access": "public" + }, + "typescript": { + "definition": "dist/typings/index.d.ts" + } +} diff --git a/packages/plugins/root-level-limitation/src/index.ts b/packages/plugins/root-level-limitation/src/index.ts new file mode 100644 index 0000000000..3af0eb06ee --- /dev/null +++ b/packages/plugins/root-level-limitation/src/index.ts @@ -0,0 +1,47 @@ +import { createGraphQLError, getRootTypes } from '@graphql-tools/utils'; +import { Plugin } from '@envelop/core'; +import type { ValidationRule } from 'graphql/validation/ValidationContext'; +import { isObjectType } from 'graphql'; + +export interface RootLevelQueryLimitOptions { + maxRootLevelFields: number; +} + +export function createRootLevelQueryLimitRule(opts: RootLevelQueryLimitOptions): ValidationRule { + const { maxRootLevelFields } = opts; + + return function rootLevelQueryLimitRule (context) { + const rootTypes = getRootTypes(context.getSchema()); + let rootFieldCount = 0; + return { + Field() { + const parentType = context.getParentType(); + if (isObjectType(parentType) && rootTypes.has(parentType)) { + rootFieldCount++; + if (rootFieldCount > maxRootLevelFields) { + throw createGraphQLError('Query is too complex.', { + extensions: { + http: { + spec: false, + status: 400, + }, + }, + }); + } + } + }, + }; + }; + +} + +export function rootLevelQueryLimit(opts: RootLevelQueryLimitOptions): Plugin { + const rootLevelQueryLimitRule = createRootLevelQueryLimitRule(opts); + return { + onValidate({ addValidationRule }) { + addValidationRule( + rootLevelQueryLimitRule + ) + } + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index daca3ff25a..5d092d2c8e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1994,6 +1994,26 @@ importers: version: 2.6.2 publishDirectory: dist + packages/plugins/root-level-limitation: + dependencies: + '@graphql-tools/utils': + specifier: ^10.1.0 + version: 10.1.1(graphql@16.6.0) + tslib: + specifier: ^2.5.2 + version: 2.6.2 + devDependencies: + '@envelop/core': + specifier: 4.0.0 + version: 4.0.0 + graphql: + specifier: 16.6.0 + version: 16.6.0 + graphql-yoga: + specifier: 5.3.0 + version: link:../../graphql-yoga/dist + publishDirectory: dist + packages/plugins/sofa: dependencies: graphql: @@ -6402,7 +6422,7 @@ packages: engines: {node: '>=16.0.0'} dependencies: '@envelop/types': 4.0.0 - tslib: 2.5.3 + tslib: 2.6.2 /@envelop/core@5.0.0: resolution: {integrity: sha512-aJdnH/ptv+cvwfvciCBe7TSvccBwo9g0S5f6u35TBVzRVqIGkK03lFlIL+x1cnfZgN9EfR2b1PH2galrT1CdCQ==} @@ -31309,7 +31329,7 @@ packages: peerDependencies: graphql: ^15.0.0 || ^16.0.0 dependencies: - '@graphql-tools/utils': 10.1.0(graphql@16.6.0) + '@graphql-tools/utils': 10.1.1(graphql@16.6.0) '@whatwg-node/fetch': 0.9.17 ansi-colors: 4.1.3 fets: 0.2.0 diff --git a/website/route-lockfile.txt b/website/route-lockfile.txt index 3512515d17..3054e5c4a8 100644 --- a/website/route-lockfile.txt +++ b/website/route-lockfile.txt @@ -23,6 +23,7 @@ /docs/features/persisted-operations /docs/features/request-batching /docs/features/response-caching +/docs/features/root-level-limitation /docs/features/schema /docs/features/sofa-api /docs/features/subscriptions diff --git a/website/src/pages/docs/features/_meta.ts b/website/src/pages/docs/features/_meta.ts index d457841485..eed139d4ff 100644 --- a/website/src/pages/docs/features/_meta.ts +++ b/website/src/pages/docs/features/_meta.ts @@ -9,6 +9,7 @@ export default { 'file-uploads': 'File Uploads', 'defer-stream': 'Defer and Stream', 'request-batching': 'Request Batching', + 'root-level-limitation': 'Root Level Limitation', cors: 'CORS', 'csrf-prevention': 'CSRF Prevention', 'parsing-and-validation-caching': 'Parsing and Validation Caching', diff --git a/website/src/pages/docs/features/root-level-limitation.mdx b/website/src/pages/docs/features/root-level-limitation.mdx new file mode 100644 index 0000000000..fc6078257b --- /dev/null +++ b/website/src/pages/docs/features/root-level-limitation.mdx @@ -0,0 +1,60 @@ +--- +description: + This plugin enforces a limit on the number of root fields allowed in a GraphQL query, similar to + the `maxRootFields` option in Apollo Router. This can help to improve the performance and + stability of your GraphQL server by preventing overly complex queries. +--- + +# Root Fields + +This plugin enforces a limit on the number of root fields allowed in a GraphQL query, similar to the +`maxRootFields` option in Apollo Router. This can help to improve the performance and stability of +your GraphQL server by preventing overly complex queries. + +Here's an example query that fetches two root fields, `field1`, `field2` and `field3`: + +``` +query GetAllFields { + field { # 1 + id + } + field2 { # 2 + id + } + field3 { # 3 + id + } +} +``` + +# Limit Root Fields + +## Installation + +```sh npm2yarn +npm i @graphql-yoga/root-level-limitation +``` + +## Quick Start + +```ts +import { createServer } from 'node:http' +import { createSchema, createYoga } from 'graphql-yoga' +import { rootLevelQueryLimit } from '@graphql-yoga/root-level-limitation' + +export const yoga = createYoga({ + schema: createSchema({ + typeDefs: /* GraphQL */ ` + type Query { + hello: String! + } + ` + }), + plugins: [rootLevelQueryLimit({ maxRootLevelFields: 1 })] +}) + +const server = createServer(yoga) +server.listen(4000, () => { + console.info('Server is running on http://localhost:4000/graphql') +}) +```