├── .github ├── dependabot.yml ├── release-drafter.yml └── workflows │ └── ci.yml ├── .gitignore ├── .taprc ├── LICENSE ├── README.md ├── bench.sh ├── bench ├── gateway-bench.js ├── gateway-post-service.js ├── gateway-user-service.js ├── gateway-with-validation.js ├── gateway-without-validation.js ├── normal-bench.js ├── normal-setup.js ├── normal-with-validation.js └── normal-without-validation.js ├── docs ├── api │ └── options.md ├── directive-validation.md ├── function-validation.md ├── json-schema-validation.md ├── jtd-validation.md └── registration.md ├── examples ├── directive-validation.js ├── gateway.js ├── json-schema-validation.js └── jtd-validation.js ├── index.d.ts ├── index.js ├── lib ├── directive.js ├── errors.js ├── symbols.js ├── utils.js ├── validation.js └── validators │ ├── directive-validator.js │ ├── function-validator.js │ ├── index.js │ ├── json-schema-validator.js │ ├── jtd-validator.js │ └── validator.js ├── package.json ├── test ├── advanced-validation.js ├── directive-definition.js ├── directive-validation.js ├── errors.js ├── function-validation.js ├── gateway-validation.js ├── json-schema-validation.js ├── jtd-validation.js ├── refresh.js ├── registration.js └── types │ └── index.test-d.ts └── tsconfig.json /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | open-pull-requests-limit: 10 8 | 9 | - package-ecosystem: "npm" 10 | directory: "/" 11 | schedule: 12 | interval: "weekly" 13 | open-pull-requests-limit: 10 14 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | template: | 2 | ## What’s Changed 3 | 4 | $CHANGES 5 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI workflow 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | matrix: 10 | node-version: [18.x, 20.x] 11 | os: [ubuntu-latest, windows-latest, macOS-latest] 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Use Node.js ${{ matrix.node-version }} 15 | uses: actions/setup-node@v4 16 | with: 17 | node-version: ${{ matrix.node-version }} 18 | - name: Install Dependencies 19 | run: npm install --ignore-scripts 20 | - name: Test 21 | run: npm test 22 | 23 | automerge: 24 | needs: test 25 | runs-on: ubuntu-latest 26 | permissions: 27 | pull-requests: write 28 | contents: write 29 | steps: 30 | - uses: fastify/github-action-merge-dependabot@v3 31 | with: 32 | github-token: ${{secrets.GITHUB_TOKEN}} 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | package-lock.json 107 | -------------------------------------------------------------------------------- /.taprc: -------------------------------------------------------------------------------- 1 | ts: false 2 | jsx: false 3 | flow: false 4 | coverage: true 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Jonny Green 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mercurius-validation 2 | 3 | ![CI workflow](https://github.com/mercurius-js/validation/workflows/CI%20workflow/badge.svg) 4 | 5 | Mercurius Validation is a plugin for [Mercurius](https://mercurius.dev) that adds configurable validation support. 6 | 7 | Features: 8 | 9 | - Supports JSON Schema. 10 | - Supports JTD. 11 | - Supports Custom Function validation on field arguments. 12 | - Supports validation through `constraint` directives in your schema. This plugin will apply JSON Schema validation policies based on the matching directives. 13 | - Provides SDL type definitions for the GraphQL directive. 14 | - Works in both normal and gateway mode. 15 | - In addition to the argument metadata and value, custom functions have access to the same GraphQL information that any GraphQL resolver has access to. 16 | - Define custom errors. 17 | - GraphQL spec compliant. 18 | 19 | ## Docs 20 | 21 | - [Install](#install) 22 | - [Quick Start](#quick-start) 23 | - [Validation with JSON SChema definitions](#validation-with-json-schema-definitions) 24 | - [Validation with the GraphQL `@constraint` directive](#validation-with-the-graphql-constraint-directive) 25 | - [Examples](#examples) 26 | - [Benchmarks](#benchmarks) 27 | - [API](docs/api/options.md) 28 | - [Registration](docs/registration.md) 29 | - [JSON Schema Validation](docs/json-schema-validation.md) 30 | - [JTD Validation](docs/jtd-validation.md) 31 | - [Function Validation](docs/function-validation.md) 32 | - [Directive Validation](docs/directive-validation.md) 33 | 34 | ## Install 35 | 36 | ```bash 37 | npm i fastify mercurius mercurius-validation 38 | ``` 39 | 40 | ## Quick Start 41 | 42 | ### Validation with JSON Schema definitions 43 | 44 | You can setup `mercurius-validation` using a JSON Schema validation definition as follows: 45 | 46 | ```js 47 | 'use strict' 48 | 49 | const Fastify = require('fastify') 50 | const mercurius = require('mercurius') 51 | const mercuriusValidation = require('mercurius-validation') 52 | 53 | const schema = ` 54 | type Message { 55 | id: ID! 56 | text: String 57 | } 58 | 59 | input Filters { 60 | id: ID 61 | text: String 62 | } 63 | 64 | type Query { 65 | message(id: ID): Message 66 | messages(filters: Filters): [Message] 67 | } 68 | ` 69 | 70 | const messages = [ 71 | { 72 | id: 0, 73 | text: 'Some system message.' 74 | }, 75 | { 76 | id: 1, 77 | text: 'Hello there' 78 | }, 79 | { 80 | id: 2, 81 | text: 'Give me a place to stand, a lever long enough and a fulcrum. And I can move the Earth.' 82 | }, 83 | { 84 | id: 3, 85 | text: '' 86 | } 87 | ] 88 | 89 | const resolvers = { 90 | Query: { 91 | message: async (_, { id }) => { 92 | return messages.find(message => message.id === Number(id)) 93 | }, 94 | messages: async () => { 95 | return messages 96 | } 97 | } 98 | } 99 | 100 | const app = Fastify() 101 | 102 | app.register(mercurius, { 103 | schema, 104 | resolvers 105 | }) 106 | 107 | app.register(mercuriusValidation, { 108 | schema: { 109 | Filters: { 110 | text: { type: 'string', minLength: 1 } 111 | }, 112 | Query: { 113 | message: { 114 | id: { type: 'string', minLength: 1 } 115 | } 116 | } 117 | } 118 | }) 119 | 120 | app.listen({ port: 3000 }) 121 | ``` 122 | 123 | ### Validation with the GraphQL `@constraint` directive 124 | 125 | You can setup `mercurius-validation` with the `@constraint` GraphQL directive. `mercurius-validation` provides the type definitions to include this directive definition within your GraphQL schema. 126 | 127 | ```js 128 | 'use strict' 129 | 130 | const Fastify = require('fastify') 131 | const mercurius = require('mercurius') 132 | const mercuriusValidation = require('mercurius-validation') 133 | 134 | const schema = ` 135 | ${mercuriusValidation.graphQLTypeDefs} 136 | 137 | type Message { 138 | id: ID! 139 | text: String 140 | } 141 | 142 | input Filters { 143 | id: ID 144 | text: String @constraint(minLength: 1) 145 | } 146 | 147 | type Query { 148 | message(id: ID @constraint(type: "string", minLength: 1)): Message 149 | messages(filters: Filters): [Message] 150 | } 151 | ` 152 | 153 | const messages = [ 154 | { 155 | id: 0, 156 | text: 'Some system message.' 157 | }, 158 | { 159 | id: 1, 160 | text: 'Hello there' 161 | }, 162 | { 163 | id: 2, 164 | text: 'Give me a place to stand, a lever long enough and a fulcrum. And I can move the Earth.' 165 | }, 166 | { 167 | id: 3, 168 | text: '' 169 | } 170 | ] 171 | 172 | const resolvers = { 173 | Query: { 174 | message: async (_, { id }) => { 175 | return messages.find(message => message.id === Number(id)) 176 | }, 177 | messages: async () => { 178 | return messages 179 | } 180 | } 181 | } 182 | 183 | const app = Fastify() 184 | app.register(mercurius, { 185 | schema, 186 | resolvers 187 | }) 188 | app.register(mercuriusValidation) 189 | 190 | app.listen({ port: 3000 }) 191 | ``` 192 | 193 | ## Benchmarks 194 | 195 | ### Normal GraphQL Server Mode | Without Validation 196 | 197 | Last run: `2021-09-27` 198 | 199 | ```text 200 | Running 10s test @ http://127.0.0.1:3000/graphql 201 | 100 connections 202 | 203 | ┌─────────┬──────┬──────┬───────┬───────┬─────────┬─────────┬───────┐ 204 | │ Stat │ 2.5% │ 50% │ 97.5% │ 99% │ Avg │ Stdev │ Max │ 205 | ├─────────┼──────┼──────┼───────┼───────┼─────────┼─────────┼───────┤ 206 | │ Latency │ 4 ms │ 5 ms │ 8 ms │ 14 ms │ 5.33 ms │ 2.28 ms │ 63 ms │ 207 | └─────────┴──────┴──────┴───────┴───────┴─────────┴─────────┴───────┘ 208 | ┌───────────┬────────┬────────┬─────────┬─────────┬─────────┬─────────┬─────────┐ 209 | │ Stat │ 1% │ 2.5% │ 50% │ 97.5% │ Avg │ Stdev │ Min │ 210 | ├───────────┼────────┼────────┼─────────┼─────────┼─────────┼─────────┼─────────┤ 211 | │ Req/Sec │ 9831 │ 9831 │ 18111 │ 19023 │ 17069.1 │ 2470.46 │ 9827 │ 212 | ├───────────┼────────┼────────┼─────────┼─────────┼─────────┼─────────┼─────────┤ 213 | │ Bytes/Sec │ 4.3 MB │ 4.3 MB │ 7.91 MB │ 8.31 MB │ 7.46 MB │ 1.08 MB │ 4.29 MB │ 214 | └───────────┴────────┴────────┴─────────┴─────────┴─────────┴─────────┴─────────┘ 215 | 216 | Req/Bytes counts sampled once per second. 217 | 218 | 188k requests in 11.03s, 82 MB read 219 | ``` 220 | 221 | ### Normal GraphQL Server Mode | With Validation 222 | 223 | Last run: `2021-09-27` 224 | 225 | ```text 226 | Running 10s test @ http://127.0.0.1:3000/graphql 227 | 100 connections 228 | 229 | ┌─────────┬──────┬──────┬───────┬───────┬─────────┬─────────┬───────┐ 230 | │ Stat │ 2.5% │ 50% │ 97.5% │ 99% │ Avg │ Stdev │ Max │ 231 | ├─────────┼──────┼──────┼───────┼───────┼─────────┼─────────┼───────┤ 232 | │ Latency │ 4 ms │ 5 ms │ 8 ms │ 15 ms │ 5.48 ms │ 2.07 ms │ 55 ms │ 233 | └─────────┴──────┴──────┴───────┴───────┴─────────┴─────────┴───────┘ 234 | ┌───────────┬─────────┬─────────┬─────────┬─────────┬──────────┬─────────┬─────────┐ 235 | │ Stat │ 1% │ 2.5% │ 50% │ 97.5% │ Avg │ Stdev │ Min │ 236 | ├───────────┼─────────┼─────────┼─────────┼─────────┼──────────┼─────────┼─────────┤ 237 | │ Req/Sec │ 9399 │ 9399 │ 17215 │ 18943 │ 16704.73 │ 2427.11 │ 9398 │ 238 | ├───────────┼─────────┼─────────┼─────────┼─────────┼──────────┼─────────┼─────────┤ 239 | │ Bytes/Sec │ 4.11 MB │ 4.11 MB │ 7.52 MB │ 8.27 MB │ 7.3 MB │ 1.06 MB │ 4.11 MB │ 240 | └───────────┴─────────┴─────────┴─────────┴─────────┴──────────┴─────────┴─────────┘ 241 | 242 | Req/Bytes counts sampled once per second. 243 | 244 | 184k requests in 11.03s, 80.3 MB read 245 | ``` 246 | 247 | ### Gateway GraphQL Server Mode | Without Validation 248 | 249 | Last run: `2021-09-27` 250 | 251 | ```text 252 | Running 10s test @ http://127.0.0.1:3000/graphql 253 | 100 connections 254 | 255 | ┌─────────┬───────┬───────┬───────┬────────┬──────────┬──────────┬────────┐ 256 | │ Stat │ 2.5% │ 50% │ 97.5% │ 99% │ Avg │ Stdev │ Max │ 257 | ├─────────┼───────┼───────┼───────┼────────┼──────────┼──────────┼────────┤ 258 | │ Latency │ 32 ms │ 38 ms │ 71 ms │ 100 ms │ 40.55 ms │ 13.79 ms │ 237 ms │ 259 | └─────────┴───────┴───────┴───────┴────────┴──────────┴──────────┴────────┘ 260 | ┌───────────┬────────┬────────┬────────┬────────┬─────────┬────────┬────────┐ 261 | │ Stat │ 1% │ 2.5% │ 50% │ 97.5% │ Avg │ Stdev │ Min │ 262 | ├───────────┼────────┼────────┼────────┼────────┼─────────┼────────┼────────┤ 263 | │ Req/Sec │ 1079 │ 1079 │ 2577 │ 2853 │ 2434.28 │ 493.75 │ 1079 │ 264 | ├───────────┼────────┼────────┼────────┼────────┼─────────┼────────┼────────┤ 265 | │ Bytes/Sec │ 378 kB │ 378 kB │ 902 kB │ 998 kB │ 852 kB │ 173 kB │ 378 kB │ 266 | └───────────┴────────┴────────┴────────┴────────┴─────────┴────────┴────────┘ 267 | 268 | Req/Bytes counts sampled once per second. 269 | 270 | 27k requests in 11.03s, 9.37 MB read 271 | ``` 272 | 273 | ### Gateway GraphQL Server Mode | With Validation 274 | 275 | Last run: `2021-09-27` 276 | 277 | ```text 278 | Running 10s test @ http://127.0.0.1:3000/graphql 279 | 100 connections 280 | 281 | ┌─────────┬───────┬───────┬───────┬────────┬──────────┬──────────┬────────┐ 282 | │ Stat │ 2.5% │ 50% │ 97.5% │ 99% │ Avg │ Stdev │ Max │ 283 | ├─────────┼───────┼───────┼───────┼────────┼──────────┼──────────┼────────┤ 284 | │ Latency │ 32 ms │ 35 ms │ 70 ms │ 103 ms │ 37.97 ms │ 13.33 ms │ 216 ms │ 285 | └─────────┴───────┴───────┴───────┴────────┴──────────┴──────────┴────────┘ 286 | ┌───────────┬────────┬────────┬────────┬─────────┬────────┬────────┬────────┐ 287 | │ Stat │ 1% │ 2.5% │ 50% │ 97.5% │ Avg │ Stdev │ Min │ 288 | ├───────────┼────────┼────────┼────────┼─────────┼────────┼────────┼────────┤ 289 | │ Req/Sec │ 1153 │ 1153 │ 2711 │ 2969 │ 2597.4 │ 521.83 │ 1153 │ 290 | ├───────────┼────────┼────────┼────────┼─────────┼────────┼────────┼────────┤ 291 | │ Bytes/Sec │ 404 kB │ 404 kB │ 949 kB │ 1.04 MB │ 909 kB │ 183 kB │ 404 kB │ 292 | └───────────┴────────┴────────┴────────┴─────────┴────────┴────────┴────────┘ 293 | 294 | Req/Bytes counts sampled once per second. 295 | 296 | 26k requests in 10.03s, 9.09 MB read 297 | ``` 298 | 299 | ## Examples 300 | 301 | Check [GitHub repo](https://github.com/mercurius-js/validation/tree/master/examples) for more examples. 302 | 303 | ## License 304 | 305 | MIT 306 | -------------------------------------------------------------------------------- /bench.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | echo '====================================' 4 | echo '= Normal Mode | Without Validation =' 5 | echo '====================================' 6 | npx concurrently --raw -k \ 7 | "node ./bench/normal-without-validation.js" \ 8 | "npx wait-on tcp:3000 && node ./bench/normal-bench.js" 9 | 10 | echo '=================================' 11 | echo '= Normal Mode | With Validation =' 12 | echo '=================================' 13 | npx concurrently --raw -k \ 14 | "node ./bench/normal-with-validation.js" \ 15 | "npx wait-on tcp:3000 && node ./bench/normal-bench.js" 16 | 17 | echo '=====================================' 18 | echo '= Gateway Mode | Without Validation =' 19 | echo '=====================================' 20 | npx concurrently --raw -k \ 21 | "node ./bench/gateway-user-service.js" \ 22 | "node ./bench/gateway-post-service.js" \ 23 | "npx wait-on tcp:3001 tcp:3002 && node ./bench/gateway-without-validation.js" \ 24 | "npx wait-on tcp:3000 && node ./bench/gateway-bench.js" 25 | 26 | echo '==================================' 27 | echo '= Gateway Mode | With Validation =' 28 | echo '==================================' 29 | npx concurrently --raw -k \ 30 | "node ./bench/gateway-user-service.js" \ 31 | "node ./bench/gateway-post-service.js" \ 32 | "npx wait-on tcp:3001 tcp:3002 && node ./bench/gateway-with-validation.js" \ 33 | "npx wait-on tcp:3000 && node ./bench/gateway-bench.js" 34 | -------------------------------------------------------------------------------- /bench/gateway-bench.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const autocannon = require('autocannon') 4 | 5 | const query = ` 6 | query { 7 | me(id: 1) { 8 | id 9 | name 10 | nickname: name 11 | topPosts(count: 2) { 12 | pid 13 | author { 14 | id 15 | } 16 | } 17 | } 18 | topPosts(count: 2) { 19 | pid 20 | } 21 | }` 22 | 23 | const instance = autocannon( 24 | { 25 | url: 'http://127.0.0.1:3000/graphql', 26 | connections: 100, 27 | title: '', 28 | method: 'POST', 29 | headers: { 30 | 'content-type': 'application/json' 31 | }, 32 | body: JSON.stringify({ query }) 33 | }, 34 | (err) => { 35 | if (err) { 36 | console.error(err) 37 | } 38 | } 39 | ) 40 | 41 | process.once('SIGINT', () => { 42 | instance.stop() 43 | }) 44 | 45 | autocannon.track(instance, { renderProgressBar: true }) 46 | -------------------------------------------------------------------------------- /bench/gateway-post-service.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Fastify = require('fastify') 4 | const mercurius = require('mercurius') 5 | 6 | const app = Fastify() 7 | 8 | const posts = { 9 | p1: { 10 | pid: 'p1', 11 | title: 'Post 1', 12 | content: 'Content 1', 13 | authorId: 'u1' 14 | }, 15 | p2: { 16 | pid: 'p2', 17 | title: 'Post 2', 18 | content: 'Content 2', 19 | authorId: 'u2' 20 | }, 21 | p3: { 22 | pid: 'p3', 23 | title: 'Post 3', 24 | content: 'Content 3', 25 | authorId: 'u1' 26 | }, 27 | p4: { 28 | pid: 'p4', 29 | title: 'Post 4', 30 | content: 'Content 4', 31 | authorId: 'u1' 32 | } 33 | } 34 | 35 | const schema = ` 36 | type Post @key(fields: "pid") { 37 | pid: ID! 38 | author: User 39 | } 40 | 41 | extend type Query { 42 | topPosts(count: Int): [Post] 43 | } 44 | 45 | type User @key(fields: "id") @extends { 46 | id: ID! @external 47 | topPosts(count: Int!): [Post] 48 | }` 49 | 50 | const resolvers = { 51 | Post: { 52 | __resolveReference: (post, args, context, info) => { 53 | return posts[post.pid] 54 | }, 55 | author: (post, args, context, info) => { 56 | return { 57 | __typename: 'User', 58 | id: post.authorId 59 | } 60 | } 61 | }, 62 | User: { 63 | topPosts: (user, { count }, context, info) => { 64 | return Object.values(posts).filter(p => p.authorId === user.id).slice(0, count) 65 | } 66 | }, 67 | Query: { 68 | topPosts: (root, { count = 2 }) => Object.values(posts).slice(0, count) 69 | } 70 | } 71 | 72 | app.register(mercurius, { 73 | schema, 74 | resolvers, 75 | federationMetadata: true, 76 | graphiql: false, 77 | jit: 1 78 | }) 79 | 80 | app.listen({ port: 3002 }) 81 | -------------------------------------------------------------------------------- /bench/gateway-user-service.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Fastify = require('fastify') 4 | const mercurius = require('mercurius') 5 | 6 | const app = Fastify() 7 | 8 | const users = { 9 | u1: { 10 | id: 'u1', 11 | name: 'John' 12 | }, 13 | u2: { 14 | id: 'u2', 15 | name: 'Jane' 16 | } 17 | } 18 | 19 | const schema = ` 20 | type Query @extends { 21 | me(id: Int): User 22 | } 23 | 24 | type User @key(fields: "id") { 25 | id: ID! 26 | name: String 27 | }` 28 | 29 | const resolvers = { 30 | Query: { 31 | me: (root, args, context, info) => { 32 | return users.u1 33 | } 34 | }, 35 | User: { 36 | __resolveReference: (user, args, context, info) => { 37 | return users[user.id] 38 | } 39 | } 40 | } 41 | 42 | app.register(mercurius, { 43 | schema, 44 | resolvers, 45 | federationMetadata: true, 46 | graphiql: false, 47 | jit: 1 48 | }) 49 | 50 | app.listen({ port: 3001 }) 51 | -------------------------------------------------------------------------------- /bench/gateway-with-validation.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Fastify = require('fastify') 4 | const mercurius = require('mercurius') 5 | const mercuriusValidation = require('..') 6 | 7 | const app = Fastify() 8 | 9 | app.register(mercurius, { 10 | gateway: { 11 | services: [{ 12 | name: 'user', 13 | url: 'http://127.0.0.1:3001/graphql' 14 | }, { 15 | name: 'post', 16 | url: 'http://127.0.0.1:3002/graphql' 17 | }] 18 | }, 19 | graphiql: false, 20 | jit: 1 21 | }) 22 | 23 | app.register(mercuriusValidation, { 24 | schema: { 25 | User: { 26 | topPosts: { 27 | count: { type: 'integer', minimum: 1 } 28 | } 29 | }, 30 | Query: { 31 | me: { 32 | id: { type: 'integer', minimum: 1 } 33 | }, 34 | topPosts: { 35 | count: { type: 'integer', minimum: 1 } 36 | } 37 | } 38 | } 39 | }) 40 | 41 | app.listen({ port: 3000 }) 42 | -------------------------------------------------------------------------------- /bench/gateway-without-validation.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Fastify = require('fastify') 4 | const mercurius = require('mercurius') 5 | 6 | const app = Fastify() 7 | 8 | app.register(mercurius, { 9 | gateway: { 10 | services: [{ 11 | name: 'user', 12 | url: 'http://127.0.0.1:3001/graphql' 13 | }, { 14 | name: 'post', 15 | url: 'http://127.0.0.1:3002/graphql' 16 | }] 17 | }, 18 | graphiql: false, 19 | jit: 1 20 | }) 21 | 22 | app.listen({ port: 3000 }) 23 | -------------------------------------------------------------------------------- /bench/normal-bench.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const autocannon = require('autocannon') 4 | 5 | const query = `query { 6 | message(id: "1") { 7 | id 8 | text 9 | } 10 | messages( 11 | filters: { text: "hello"} 12 | nestedFilters: { input: { text: "hello"} } 13 | arrayScalarFilters: ["hello"] 14 | arrayObjectFilters: [{ filters: { text: "hello" }}] 15 | ) { 16 | id 17 | text 18 | } 19 | }` 20 | 21 | const instance = autocannon( 22 | { 23 | url: 'http://127.0.0.1:3000/graphql', 24 | connections: 100, 25 | title: '', 26 | method: 'POST', 27 | headers: { 28 | 'content-type': 'application/json', 'x-user': 'admin' 29 | }, 30 | body: JSON.stringify({ query }) 31 | }, 32 | (err) => { 33 | if (err) { 34 | console.error(err) 35 | } 36 | } 37 | ) 38 | 39 | process.once('SIGINT', () => { 40 | instance.stop() 41 | }) 42 | 43 | autocannon.track(instance, { renderProgressBar: true }) 44 | -------------------------------------------------------------------------------- /bench/normal-setup.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const schema = ` 4 | type Message { 5 | id: ID! 6 | text: String 7 | } 8 | 9 | input Filters { 10 | id: ID 11 | text: String 12 | } 13 | 14 | input NestedFilters { 15 | input: Filters 16 | } 17 | 18 | input ArrayFilters { 19 | values: [String] 20 | filters: [Filters] 21 | } 22 | 23 | type Query { 24 | message(id: ID): Message 25 | messages( 26 | filters: Filters 27 | nestedFilters: NestedFilters 28 | arrayScalarFilters: [String] 29 | arrayObjectFilters: [ArrayFilters] 30 | ): [Message] 31 | } 32 | ` 33 | 34 | const messages = [ 35 | { 36 | id: 0, 37 | text: 'Some system message.' 38 | }, 39 | { 40 | id: 1, 41 | text: 'Hello there' 42 | }, 43 | { 44 | id: 2, 45 | text: 'Give me a place to stand, a lever long enough and a fulcrum. And I can move the Earth.' 46 | }, 47 | { 48 | id: 3, 49 | text: '' 50 | } 51 | ] 52 | 53 | const resolvers = { 54 | Query: { 55 | message: async (_, { id }) => { 56 | return messages.find(message => message.id === Number(id)) 57 | }, 58 | messages: async () => { 59 | return messages 60 | } 61 | } 62 | } 63 | 64 | module.exports = { 65 | schema, 66 | resolvers 67 | } 68 | -------------------------------------------------------------------------------- /bench/normal-with-validation.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Fastify = require('fastify') 4 | const mercurius = require('mercurius') 5 | const mercuriusValidation = require('..') 6 | const { schema, resolvers } = require('./normal-setup') 7 | 8 | const app = Fastify() 9 | 10 | app.register(mercurius, { 11 | schema, 12 | resolvers, 13 | graphiql: false, 14 | jit: 1 15 | }) 16 | 17 | app.register(mercuriusValidation, { 18 | schema: { 19 | Filters: { 20 | text: { type: 'string', minLength: 1 } 21 | }, 22 | Query: { 23 | message: { 24 | id: { type: 'string', minLength: 1 } 25 | }, 26 | messages: { 27 | arrayScalarFilters: { 28 | type: 'array', 29 | items: { 30 | type: 'string', 31 | minLength: 1 32 | }, 33 | minItems: 1 34 | }, 35 | arrayObjectFilters: { 36 | type: 'array', 37 | minItems: 1 38 | } 39 | } 40 | } 41 | } 42 | }) 43 | 44 | app.listen({ port: 3000 }) 45 | -------------------------------------------------------------------------------- /bench/normal-without-validation.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Fastify = require('fastify') 4 | const mercurius = require('mercurius') 5 | const { schema, resolvers } = require('./normal-setup') 6 | 7 | const app = Fastify() 8 | 9 | app.register(mercurius, { 10 | schema, 11 | resolvers, 12 | graphiql: false, 13 | jit: 1 14 | }) 15 | 16 | app.listen({ port: 3000 }) 17 | -------------------------------------------------------------------------------- /docs/api/options.md: -------------------------------------------------------------------------------- 1 | # mercurius-validation 2 | 3 | - [Plugin options](#plugin-options) 4 | 5 | ## Plugin options 6 | 7 | **mercurius-validation** supports the following options: 8 | 9 | Extends: [`AJVOptions`](https://ajv.js.org/options.html) 10 | 11 | * **mode** `"JSONSchema" | "JTD"` (optional, default: `"JSONSchema"`) - the validation mode of the plugin. This is used to specify the type of schema that needs to be compiled. 12 | * **schema** `MercuriusValidationSchema` (optional) - the validation schema definition that the plugin with run. One can define JSON Schema or JTD definitions for GraphQL types, fields and arguments or functions for GraphQL arguments. 13 | * **directiveValidation** `boolean` (optional, default: `true`) - turn directive validation on or off. It is on by default. 14 | * **customTypeInferenceFn** `Function` (optional) - add custom type inference for JSON Schema Types. This function overrides the default type inference logic which infers GraphQL primitives like `GraphQLString`, `GraphQLInt` and `GraphQLFloat`. If the custom function doesn't handle the passed type, then it should return a falsy value which will trigger the default type inference logic of the plugin. This function takes two parameters. The first parameter is `type` referring to the GraphQL type under inference, while the second one is `isNonNull`, a boolean value referring whether the value for the type is nullable. 15 | 16 | It extends the [AJV options](https://ajv.js.org/options.html). These can be used to register additional `formats` for example and provide further customization to the AJV validation behavior. 17 | 18 | ### Parameter: `MercuriusValidationSchema` 19 | 20 | Extends: `Record` 21 | 22 | Each key within the `MercuriusValidationSchema` type corresponds with the GraphQL type name. For example, if we wanted validation on input type: 23 | 24 | ```gql 25 | input Filters { 26 | ... 27 | } 28 | ``` 29 | 30 | We would use the key: `Filters`: 31 | 32 | ```js 33 | { 34 | Filters: { ... } 35 | } 36 | ``` 37 | 38 | ### Parameter: `MercuriusValidationSchemaType` 39 | 40 | Extends: `Record` 41 | 42 | * **__typeValidation** `JSONSchema | JTD` (optional) - The [JSON Schema](https://json-schema.org/understanding-json-schema/) or [JTD](https://jsontypedef.com/docs/) schema definitions for the type. This is only applicable to GraphQL Input object types, so only schema definitions for `object` are applicable here. 43 | 44 | Each key within the `MercuriusValidationSchemaType` type corresponds with the GraphQL field name on a type. For example, if we wanted validation on type field `text`: 45 | 46 | ```gql 47 | input Filters { 48 | id: ID 49 | text: String 50 | } 51 | ``` 52 | 53 | We would use the key: `text`: 54 | 55 | ```js 56 | { 57 | Filters: { 58 | text: { ... } 59 | } 60 | } 61 | ``` 62 | 63 | ### Parameter: `MercuriusValidationSchemaField` 64 | 65 | The field definition is different for GraphQL Input Object types and GraphQL Object types. 66 | 67 | #### GraphQL Input Object Types 68 | 69 | Union: `JSONSchema | JTD` 70 | 71 | #### GraphQL Object Types 72 | 73 | Extends: `Record` 74 | 75 | Each key within the `MercuriusValidationSchemaField` type corresponds with the GraphQL argument name on a field. For example, if we wanted validation on field argument `id`: 76 | 77 | ```gql 78 | type Query { 79 | message(id: ID): String 80 | } 81 | ``` 82 | 83 | We would use the key: `id`: 84 | 85 | ```js 86 | { 87 | Query: { 88 | message: { 89 | id: {... } 90 | } 91 | } 92 | } 93 | ``` 94 | 95 | ### Parameter: `MercuriusValidationSchemaArgument` 96 | 97 | Union: `JSONSchema | JTD` | `MercuriusValidationFunction` 98 | 99 | ### Parameter: `MercuriusValidationFunction(metadata, value, parent, arguments, context, info)` 100 | 101 | Arguments: 102 | 103 | * **metadata** `MercuriusValidationFunctionMetadata` - the GraphQL argument metadata associated with the function definition. 104 | * **value** `any` - the value of the argument. 105 | * **parent** `object` - the parent data associated with the GraphQL field. 106 | * **arguments** `object` - the key value object of the GraphQL field arguments. 107 | * **context** `MercuriusContext` - the [Mercurius context](https://mercurius.dev/#/docs/context). 108 | * **info** `GraphQLResolveInfo` - the [GraphQL Resolve info](https://graphql.org/graphql-js/type/#graphqlobjecttype) of the object type. 109 | 110 | #### Parameter: `MercuriusValidationFunctionMetadata` 111 | 112 | * **type** `string` - the name of the associated GraphQL type. 113 | * **field** `string` - the name of the associated GraphQL field. 114 | * **argument** `string` - the name of the associated GraphQL argument. 115 | 116 | Returns: `void` 117 | 118 | ### Parameter: `JSONSchema` 119 | 120 | The [JSON Schema](https://json-schema.org/understanding-json-schema/) schema definition for the input object type, type field or field argument. 121 | 122 | ### Parameter: `JTD` 123 | 124 | The [JTD](https://jsontypedef.com/docs/) schema definition for the input object type, type field or field argument. 125 | -------------------------------------------------------------------------------- /docs/directive-validation.md: -------------------------------------------------------------------------------- 1 | # Directive validation 2 | 3 | - [Using the GraphQL definitions within your schema](#using-the-graphql-definitions-within-your-schema) 4 | - [GraphQL argument validation](#graphql-argument-validation) 5 | - [GraphQL Input Object type field validation](#graphql-input-object-type-field-validation) 6 | - [GraphQL Input Object type validation](#graphql-input-object-type-validation) 7 | - [Additional AJV options](#additional-ajv-options) 8 | - [Turning off directive validation](#turning-off-directive-validation) 9 | - [Unsupported JSON Schema keywords](#unsupported-json-schema-keywords) 10 | 11 | By default, Mercurius validation supports `@constraint` Directives out of the box. It is defined as follows: 12 | 13 | ```gql 14 | directive @constraint( 15 | maximum: Int 16 | minimum: Int 17 | exclusiveMaximum: Int 18 | exclusiveMinimum: Int 19 | multipleOf: Int 20 | maxLength: Int 21 | minLength: Int 22 | pattern: String 23 | maxProperties: Int 24 | minProperties: Int 25 | required: [String!] 26 | maxItems: Int 27 | minItems: Int 28 | uniqueItems: Boolean 29 | type: [String!] 30 | format: String 31 | schema: String 32 | ) on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION | INPUT_OBJECT 33 | ``` 34 | 35 | Every argument of the constraint directive is corresponds with its JSON Schema keyword equivalent and exhibits the same behavior. You can find the JSON Schema documentation [here](https://json-schema.org/understanding-json-schema/). At the moment, we have restricted to primitive types only, but we plan to support more keywords in the future. 36 | 37 | You can define this constraint at the following locations: 38 | 39 | - `ARGUMENT_DEFINITION` 40 | - `INPUT_FIELD_DEFINITION` 41 | - `INPUT_OBJECT` 42 | 43 | For example: 44 | 45 | ```gql 46 | type Query { 47 | message(id: ID @constraint(type: "string", minLength: 1)): String 48 | } 49 | ``` 50 | 51 | To get up and running, you can even register the plugin without options (it also works alongside JSON Schema, JTD and function validators). 52 | 53 | ```js 54 | app.register(mercuriusValidation) 55 | ``` 56 | 57 | ## Using the GraphQL definitions within your schema 58 | 59 | `mercurius-validation` provides `GraphQLDirective` and type definitions to allow one to use the `@constraint` directive within a GraphQL schema. 60 | 61 | For string-based schema definitions, you can use as follows: 62 | 63 | ```js 64 | 'use strict' 65 | 66 | const mercuriusValidation = require('mercurius-validation') 67 | 68 | const schema = ` 69 | ${mercuriusValidation.graphQLTypeDefs} 70 | 71 | type Message { 72 | id: ID! 73 | text: String 74 | } 75 | 76 | input Filters { 77 | id: ID 78 | text: String @constraint(minLength: 1) 79 | } 80 | 81 | type Query { 82 | message(id: ID @constraint(type: "string", minLength: 1)): Message 83 | messages(filters: Filters): [Message] 84 | } 85 | ` 86 | ``` 87 | 88 | For executable schema definitions, you can use as follows: 89 | 90 | ```js 91 | 'use strict' 92 | 93 | const { parse, GraphQLSchema } = require('graphql') 94 | const mercuriusValidation = require('mercurius-validation') 95 | 96 | // Define your executable schema as normal 97 | const graphQLSchemaToExtend = new GraphQLSchema({ ... }) 98 | 99 | const schema = extendSchema(graphQLSchemaToExtend, parse(mercuriusValidation.graphQLTypeDefs)) 100 | ``` 101 | 102 | ## GraphQL argument validation 103 | 104 | If we wanted to make sure the `id` argument had a minimum length of 1, we would define as follows: 105 | 106 | ```gql 107 | type Query { 108 | message(id: ID @constraint(type: "string", minLength: 1)): String 109 | } 110 | ``` 111 | 112 | Upon failure(s), an example GraphQL response will look like: 113 | 114 | ```json 115 | { 116 | "data": { 117 | "message": null 118 | }, 119 | "errors": [ 120 | { 121 | "message": "Failed Validation on arguments for field 'Query.message'", 122 | "locations": [ 123 | { 124 | "line": 2, 125 | "column": 3 126 | } 127 | ], 128 | "path": [ 129 | "message" 130 | ], 131 | "extensions": { 132 | "code": "MER_VALIDATION_ERR_FAILED_VALIDATION", 133 | "name": "ValidationError", 134 | "details": [ 135 | { 136 | "instancePath": "/id", 137 | "schemaPath": "#/properties/id/minLength", 138 | "keyword": "minLength", 139 | "params": { 140 | "limit": 1 141 | }, 142 | "message": "must NOT have fewer than 1 characters", 143 | "schema": 1, 144 | "parentSchema": { 145 | "type": "string", 146 | "minLength": 1, 147 | "$id": "https://mercurius.dev/validation/Query/message/id" 148 | }, 149 | "data": "" 150 | } 151 | ] 152 | } 153 | } 154 | ] 155 | } 156 | ``` 157 | 158 | ## GraphQL Input Object type field validation 159 | 160 | We would define the schema as follows if we wanted to check the length of the `text` field on the `Filters` input object type: 161 | 162 | ```gql 163 | input Filters { 164 | text: String @constraint(minLength: 1) 165 | } 166 | 167 | type Query { 168 | messages(filters: Filters): String 169 | } 170 | ``` 171 | 172 | Upon failure(s), an example GraphQL response will look like: 173 | 174 | ```json 175 | { 176 | "data": { 177 | "messages": null 178 | }, 179 | "errors": [ 180 | { 181 | "message": "Failed Validation on arguments for field 'Query.messages'", 182 | "locations": [{ 183 | "line": 2, 184 | "column": 7 185 | }], 186 | "path": [ 187 | "messages" 188 | ], 189 | "extensions": { 190 | "code": "MER_VALIDATION_ERR_FAILED_VALIDATION", 191 | "name": "ValidationError", 192 | "details": [ 193 | { 194 | "instancePath": "/filters/text", 195 | "schemaPath": "https://mercurius.dev/validation/Filters/properties/text/minLength", 196 | "keyword": "minLength", 197 | "params": { 198 | "limit": 1 199 | }, 200 | "message": "must NOT have fewer than 1 characters", 201 | "schema": 1, 202 | "parentSchema": { 203 | "$id": "https://mercurius.dev/validation/Filters/text", 204 | "type": "string", 205 | "minLength": 1 206 | }, 207 | "data": "" 208 | } 209 | ] 210 | } 211 | } 212 | ] 213 | } 214 | ``` 215 | 216 | ## GraphQL Input Object type validation 217 | 218 | We would define the schema as follows if we wanted to check the number of properties on the `Filters` input object type: 219 | 220 | ```gql 221 | input Filters @constraint(minProperties: 1) { 222 | text: String 223 | } 224 | 225 | type Query { 226 | messages(filters: Filters): String 227 | } 228 | ``` 229 | 230 | Upon failure(s), an example GraphQL response will look like: 231 | 232 | ```json 233 | { 234 | "data": { 235 | "messages": null 236 | }, 237 | "errors": [ 238 | { 239 | "message": "Failed Validation on arguments for field 'Query.messages'", 240 | "locations": [{ 241 | "line": 2, 242 | "column": 7 243 | }], 244 | "path": [ 245 | "messages" 246 | ], 247 | "extensions": { 248 | "code": "MER_VALIDATION_ERR_FAILED_VALIDATION", 249 | "name": "ValidationError", 250 | "details": [ 251 | { 252 | "instancePath": "/filters", 253 | "schemaPath": "https://mercurius.dev/validation/Filters/minProperties", 254 | "keyword": "minProperties", 255 | "params": { 256 | "limit": 1 257 | }, 258 | "message": "must NOT have fewer than 1 items", 259 | "schema": 1, 260 | "parentSchema": { 261 | "minProperties": 1, 262 | "$id": "https://mercurius.dev/validation/Filters", 263 | "type": "object", 264 | "properties": { 265 | "text": { 266 | "type": "string", 267 | "$id": "https://mercurius.dev/validation/Filters/text" 268 | } 269 | } 270 | }, 271 | "data": {} 272 | } 273 | ] 274 | } 275 | } 276 | ] 277 | } 278 | ``` 279 | 280 | ## Additional AJV options 281 | 282 | If you need to provide additional AJV options, such providing custom formats, we can provide these at plugin registration: 283 | 284 | We would define the schema as follows if we wanted to ensure the `id` argument was in a `base64` format: 285 | 286 | ```gql 287 | type Query { 288 | message(id: ID @constraint(type: "string", minLength: 1, format: "base64")): String 289 | } 290 | ``` 291 | 292 | When run, this would produce the following validation error when an input is not base64: 293 | 294 | ```json 295 | { 296 | "data": { 297 | "message": null 298 | }, 299 | "errors": [ 300 | { 301 | "message": "Failed Validation on arguments for field 'Query.message'", 302 | "locations": [ 303 | { 304 | "line": 2, 305 | "column": 3 306 | } 307 | ], 308 | "path": [ 309 | "message" 310 | ], 311 | "extensions": { 312 | "code": "MER_VALIDATION_ERR_FAILED_VALIDATION", 313 | "name": "ValidationError", 314 | "details": [ 315 | { 316 | "instancePath": "/id", 317 | "schemaPath": "#/properties/id/format", 318 | "keyword": "format", 319 | "params": { 320 | "format": "base64" 321 | }, 322 | "message": "must match format \"base64\"", 323 | "schema": "base64", 324 | "parentSchema": { 325 | "type": "string", 326 | "format": "base64", 327 | "$id": "https://mercurius.dev/validation/Query/message/id" 328 | }, 329 | "data": "not-base-64" 330 | } 331 | ] 332 | } 333 | } 334 | ] 335 | } 336 | ``` 337 | 338 | ## Turning off directive validation 339 | 340 | If you don't want to run directive validation within the plugin, you can turn it off during plugin registration: 341 | 342 | ```js 343 | app.register(mercuriusValidation, { 344 | directiveValidation: false 345 | // Additional options here 346 | }) 347 | ``` 348 | 349 | ## Unsupported JSON Schema keywords 350 | 351 | If we do not yet support a JSON Schema keyword, you can use the `schema` argument as a workaround. 352 | 353 | For example, if we wanted to use the `items` keyword, we would define the schema as follows: 354 | 355 | ```gql 356 | type Message { 357 | id: ID 358 | text: String 359 | } 360 | 361 | type Query { 362 | messages(ids: [ID] @constraint(schema: "{\"items\":{\"type\":\"integer\"}}")): [Message] 363 | } 364 | ``` 365 | 366 | We would get the following GraphQL response upon an error(s): 367 | 368 | ```json 369 | { 370 | "data": { 371 | "messages": null 372 | }, 373 | "errors": [ 374 | { 375 | "message": "Failed Validation on arguments for field 'Query.messages'", 376 | "locations": [ 377 | { 378 | "line": 2, 379 | "column": 7 380 | } 381 | ], 382 | "path": [ 383 | "messages" 384 | ], 385 | "extensions": { 386 | "code": "MER_VALIDATION_ERR_FAILED_VALIDATION", 387 | "name": "ValidationError", 388 | "details": [ 389 | { 390 | "instancePath": "/ids/0", 391 | "schemaPath": "#/properties/ids/items/type", 392 | "keyword": "type", 393 | "params": { 394 | "type": "integer" 395 | }, 396 | "message": "must be integer", 397 | "schema": "integer", 398 | "parentSchema": { 399 | "type": "integer" 400 | }, 401 | "data": "1.1" 402 | } 403 | ] 404 | } 405 | } 406 | ] 407 | } 408 | ``` 409 | -------------------------------------------------------------------------------- /docs/function-validation.md: -------------------------------------------------------------------------------- 1 | # Function validation 2 | 3 | - [GraphQL argument validation](#graphql-argument-validation) 4 | 5 | You can setup Mercurius validation to run custom functions on arguments when defining in-band validation schemas. It supports the following validation definitions: 6 | 7 | - Validation on GraphQL field arguments 8 | 9 | When defining validations for the above, a function must be of the shape: 10 | 11 | ```js 12 | async (metadata, argumentValue, parent, args, context, info) => { 13 | // Function definition that throws an error upon validation failure 14 | } 15 | ``` 16 | 17 | This is enabled by default and is applied whenever you define a matching function on a GraphQL argument: 18 | 19 | ```js 20 | app.register(mercuriusValidation, { 21 | schema: { 22 | Query: { 23 | message: { 24 | id: async (metadata, argumentValue, parent, args, context, info) => { 25 | // Function definition that throws an error upon validation failure 26 | } 27 | } 28 | } 29 | } 30 | }) 31 | ``` 32 | 33 | ## GraphQL argument validation 34 | 35 | For the following GraphQL schema: 36 | 37 | ```gql 38 | type Query { 39 | message(id: ID): String 40 | } 41 | ``` 42 | 43 | You can define function validation on the `Query.message.id` argument as follows: 44 | 45 | ```js 46 | app.register(mercuriusValidation, { 47 | schema: { 48 | Query: { 49 | message: { 50 | id: async (metadata, argumentValue) => { 51 | // Function definition that throws an error upon validation failure 52 | } 53 | } 54 | } 55 | } 56 | }) 57 | ``` 58 | 59 | For example, if we wanted to check the ID input using a custom function: 60 | 61 | ```js 62 | app.register(mercuriusValidation, { 63 | schema: { 64 | Query: { 65 | message: { 66 | id: async (metadata, argumentValue) => { 67 | if (argumentValue.length < 1) { 68 | const error = new Error('Kaboom') 69 | error.data = `Input data: '${argumentValue}'` 70 | throw error 71 | } 72 | } 73 | } 74 | } 75 | } 76 | }) 77 | ``` 78 | 79 | Upon failure(s), an example GraphQL response will look like: 80 | 81 | ```json 82 | { 83 | "data": { 84 | "message": null 85 | }, 86 | "errors": [ 87 | { 88 | "message": "Failed Validation on arguments for field 'Query.message'", 89 | "locations": [ 90 | { 91 | "line": 2, 92 | "column": 3 93 | } 94 | ], 95 | "path": [ 96 | "message" 97 | ], 98 | "extensions": { 99 | "code": "MER_VALIDATION_ERR_FAILED_VALIDATION", 100 | "name": "ValidationError", 101 | "details": [ 102 | { 103 | "data": "kaboom data" 104 | } 105 | ] 106 | } 107 | } 108 | ] 109 | } 110 | ``` 111 | -------------------------------------------------------------------------------- /docs/json-schema-validation.md: -------------------------------------------------------------------------------- 1 | # JSON Schema validation 2 | 3 | - [GraphQL argument validation](#graphql-argument-validation) 4 | - [GraphQL Input Object type field validation](#graphql-input-object-type-field-validation) 5 | - [GraphQL Input Object type validation](#graphql-input-object-type-validation) 6 | - [Additional AJV options](#additional-ajv-options) 7 | - [Custom errors](#custom-errors) 8 | - [Type inference](#type-inference) 9 | - [Caveats](#caveats) 10 | 11 | By default, Mercurius validation runs in JSON Schema mode when defining in-band validation schemas. It supports the following validation definitions: 12 | 13 | - Validation on GraphQL field arguments 14 | - Validation on Input Object type fields 15 | - Validation on Input Object types 16 | 17 | When defining validations for each of the above, any valid JSON Schema keyword is supported. 18 | 19 | ## GraphQL argument validation 20 | 21 | For the following GraphQL schema: 22 | 23 | ```gql 24 | type Query { 25 | message(id: ID): String 26 | } 27 | ``` 28 | 29 | You can define JSON Schema validation on the `Query.message.id` argument as follows: 30 | 31 | ```js 32 | app.register(mercuriusValidation, { 33 | schema: { 34 | Query: { 35 | message: { 36 | id: { ... } // Any valid JSON Schema definition 37 | } 38 | } 39 | } 40 | }) 41 | ``` 42 | 43 | For example, if we wanted to check the minimum length of the ID input: 44 | 45 | ```js 46 | app.register(mercuriusValidation, { 47 | schema: { 48 | Query: { 49 | message: { 50 | id: { type: 'string', minLength: 1 } 51 | } 52 | } 53 | } 54 | }) 55 | ``` 56 | 57 | Upon failure(s), an example GraphQL response will look like: 58 | 59 | ```json 60 | { 61 | "data": { 62 | "message": null 63 | }, 64 | "errors": [ 65 | { 66 | "message": "Failed Validation on arguments for field 'Query.message'", 67 | "locations": [ 68 | { 69 | "line": 2, 70 | "column": 3 71 | } 72 | ], 73 | "path": [ 74 | "message" 75 | ], 76 | "extensions": { 77 | "code": "MER_VALIDATION_ERR_FAILED_VALIDATION", 78 | "name": "ValidationError", 79 | "details": [ 80 | { 81 | "instancePath": "/id", 82 | "schemaPath": "#/properties/id/minLength", 83 | "keyword": "minLength", 84 | "params": { 85 | "limit": 1 86 | }, 87 | "message": "must NOT have fewer than 1 characters", 88 | "schema": 1, 89 | "parentSchema": { 90 | "type": "string", 91 | "minLength": 1, 92 | "$id": "https://mercurius.dev/validation/Query/message/id" 93 | }, 94 | "data": "" 95 | } 96 | ] 97 | } 98 | } 99 | ] 100 | } 101 | ``` 102 | 103 | ## GraphQL Input Object type field validation 104 | 105 | For the following GraphQL schema: 106 | 107 | ```gql 108 | input Filters { 109 | text: String 110 | } 111 | 112 | type Query { 113 | messages(filters: Filters): String 114 | } 115 | ``` 116 | 117 | You can define JSON Schema validation on the `Filters.text` input object type field as follows: 118 | 119 | ```js 120 | app.register(mercuriusValidation, { 121 | schema: { 122 | Filters: { 123 | text: { ... } // Any valid JSON Schema definition 124 | } 125 | } 126 | }) 127 | ``` 128 | 129 | For example, if we wanted to check the minimum length of the text input: 130 | 131 | ```js 132 | app.register(mercuriusValidation, { 133 | schema: { 134 | Filters: { 135 | text: { type: 'string', minLength: 1 } 136 | } 137 | } 138 | }) 139 | ``` 140 | 141 | Upon failure(s), an example GraphQL response will look like: 142 | 143 | ```json 144 | { 145 | "data": { 146 | "messages": null 147 | }, 148 | "errors": [ 149 | { 150 | "message": "Failed Validation on arguments for field 'Query.messages'", 151 | "locations": [{ 152 | "line": 2, 153 | "column": 7 154 | }], 155 | "path": [ 156 | "messages" 157 | ], 158 | "extensions": { 159 | "code": "MER_VALIDATION_ERR_FAILED_VALIDATION", 160 | "name": "ValidationError", 161 | "details": [ 162 | { 163 | "instancePath": "/filters/text", 164 | "schemaPath": "https://mercurius.dev/validation/Filters/properties/text/minLength", 165 | "keyword": "minLength", 166 | "params": { 167 | "limit": 1 168 | }, 169 | "message": "must NOT have fewer than 1 characters", 170 | "schema": 1, 171 | "parentSchema": { 172 | "$id": "https://mercurius.dev/validation/Filters/text", 173 | "type": "string", 174 | "minLength": 1 175 | }, 176 | "data": "" 177 | } 178 | ] 179 | } 180 | } 181 | ] 182 | } 183 | ``` 184 | 185 | ## GraphQL Input Object type validation 186 | 187 | For the following GraphQL schema: 188 | 189 | ```gql 190 | input Filters { 191 | text: String 192 | } 193 | 194 | type Query { 195 | messages(filters: Filters): String 196 | } 197 | ``` 198 | 199 | You can define JSON Schema validation on the `Filters` input object type using the reserved `__typeValidation` field as follows: 200 | 201 | ```js 202 | app.register(mercuriusValidation, { 203 | schema: { 204 | Filters: { 205 | __typeValidation: { ... } // Any valid JSON Schema definition 206 | } 207 | } 208 | }) 209 | ``` 210 | 211 | For example, if we wanted to check the minimum number of properties of the Filters input object type: 212 | 213 | ```js 214 | app.register(mercuriusValidation, { 215 | schema: { 216 | Filters: { 217 | __typeValidation: { minProperties: 1 } 218 | } 219 | } 220 | }) 221 | ``` 222 | 223 | Upon failure(s), an example GraphQL response will look like: 224 | 225 | ```json 226 | { 227 | "data": { 228 | "messages": null 229 | }, 230 | "errors": [ 231 | { 232 | "message": "Failed Validation on arguments for field 'Query.messages'", 233 | "locations": [{ 234 | "line": 2, 235 | "column": 7 236 | }], 237 | "path": [ 238 | "messages" 239 | ], 240 | "extensions": { 241 | "code": "MER_VALIDATION_ERR_FAILED_VALIDATION", 242 | "name": "ValidationError", 243 | "details": [ 244 | { 245 | "instancePath": "/filters", 246 | "schemaPath": "https://mercurius.dev/validation/Filters/minProperties", 247 | "keyword": "minProperties", 248 | "params": { 249 | "limit": 1 250 | }, 251 | "message": "must NOT have fewer than 1 items", 252 | "schema": 1, 253 | "parentSchema": { 254 | "minProperties": 1, 255 | "$id": "https://mercurius.dev/validation/Filters", 256 | "type": "object", 257 | "properties": { 258 | "text": { 259 | "type": "string", 260 | "$id": "https://mercurius.dev/validation/Filters/text" 261 | } 262 | } 263 | }, 264 | "data": {} 265 | } 266 | ] 267 | } 268 | } 269 | ] 270 | } 271 | ``` 272 | 273 | ## Additional AJV options 274 | 275 | If you need to provide additional AJV options, such providing custom formats, we can provide these at plugin registration: 276 | 277 | For the schema: 278 | 279 | ```gql 280 | type Query { 281 | message(id: ID): String 282 | } 283 | ``` 284 | 285 | For registering a new `"base64"` format: 286 | 287 | ```js 288 | app.register(mercuriusValidation, { 289 | formats: { 290 | base64: /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/ 291 | }, 292 | schema: { 293 | Query: { 294 | message: { 295 | id: { type: 'string', format: 'base64' } 296 | } 297 | } 298 | } 299 | }) 300 | ``` 301 | 302 | When run, this would produce the following validation error when an input is not base64: 303 | 304 | ```json 305 | { 306 | "data": { 307 | "message": null 308 | }, 309 | "errors": [ 310 | { 311 | "message": "Failed Validation on arguments for field 'Query.message'", 312 | "locations": [ 313 | { 314 | "line": 2, 315 | "column": 3 316 | } 317 | ], 318 | "path": [ 319 | "message" 320 | ], 321 | "extensions": { 322 | "code": "MER_VALIDATION_ERR_FAILED_VALIDATION", 323 | "name": "ValidationError", 324 | "details": [ 325 | { 326 | "instancePath": "/id", 327 | "schemaPath": "#/properties/id/format", 328 | "keyword": "format", 329 | "params": { 330 | "format": "base64" 331 | }, 332 | "message": "must match format \"base64\"", 333 | "schema": "base64", 334 | "parentSchema": { 335 | "type": "string", 336 | "format": "base64", 337 | "$id": "https://mercurius.dev/validation/Query/message/id" 338 | }, 339 | "data": "not-base-64" 340 | } 341 | ] 342 | } 343 | } 344 | ] 345 | } 346 | ``` 347 | 348 | ## Custom errors 349 | 350 | Within the plugin, we have also included the `ajv-errors` package. This adds the `errorMessage` keyword. You can use this to augment the error messages of your individual schemas. 351 | 352 | For the schema: 353 | 354 | ```gql 355 | type Query { 356 | message(id: ID): String 357 | } 358 | ``` 359 | 360 | For registering a new `"base64"` format and custom error: 361 | 362 | ```js 363 | app.register(mercuriusValidation, { 364 | formats: { 365 | base64: /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/ 366 | }, 367 | schema: { 368 | Query: { 369 | message: { 370 | id: { type: 'string', format: 'base64', errorMessage: 'Input is not valid base64.' } 371 | } 372 | } 373 | } 374 | }) 375 | ``` 376 | 377 | An error would produce the following: 378 | 379 | ```json 380 | { 381 | "data": { 382 | "message": null 383 | }, 384 | errors: [ 385 | { 386 | "message": "Failed Validation on arguments for field 'Query.message'", 387 | "locations": [ 388 | { 389 | "line": 2, 390 | "column": 7 391 | } 392 | ], 393 | "path": [ 394 | "message" 395 | ], 396 | "extensions": { 397 | "code": "MER_VALIDATION_ERR_FAILED_VALIDATION", 398 | "name": "ValidationError", 399 | "details": [ 400 | { 401 | "instancePath": "/id", 402 | "schemaPath": "#/properties/id/errorMessage", 403 | "keyword": "errorMessage", 404 | "params": { 405 | "errors": [ 406 | { 407 | "instancePath": "/id", 408 | "schemaPath": "#/properties/id/format", 409 | "keyword": "format", 410 | "params": { 411 | "format": "base64" 412 | }, 413 | "message": "must match format \"base64\"", 414 | "schema": "base64", 415 | "parentSchema": { 416 | "type": "string", 417 | "format": "base64", 418 | "errorMessage": { 419 | "format": "Input must be in base64 format." 420 | }, 421 | "$id": "https://mercurius.dev/validation/Query/message/id" 422 | }, 423 | "data": "not-base-64", 424 | "emUsed": true 425 | } 426 | ] 427 | }, 428 | "message": "Input must be in base64 format.", 429 | "schema": { 430 | "format": "Input must be in base64 format." 431 | }, 432 | "parentSchema": { 433 | "type": "string", 434 | "format": "base64", 435 | "errorMessage": { 436 | "format": "Input must be in base64 format." 437 | }, 438 | "$id": "https://mercurius.dev/validation/Query/message/id" 439 | }, 440 | "data": "not-base-64" 441 | } 442 | ] 443 | } 444 | } 445 | ] 446 | } 447 | ``` 448 | 449 | ## Type inference 450 | 451 | For some GraphQL primitives, we can infer the JSON Schema type: 452 | 453 | - `GraphQLString` <=> `{ type: 'string' }` 454 | - `GraphQLInt` <=> `{ type: 'integer' }` 455 | - `GraphQLFloat` <=> `{ type: 'number' }` 456 | 457 | In these cases, we don't necessarily need to specify this type when building the JSON schema. 458 | 459 | For the schema: 460 | 461 | ```gql 462 | type Query { 463 | message(id: String): String 464 | } 465 | ``` 466 | 467 | Registration: 468 | 469 | ```js 470 | app.register(mercuriusValidation, { 471 | schema: { 472 | Query: { 473 | message: { 474 | id: { minLength: 1 } 475 | } 476 | } 477 | } 478 | }) 479 | ``` 480 | 481 | The type inference is customizable. You can pass `customTypeInferenceFn` in the plugin options and have your own inference logic inside the function. The below code is an example for custom type inference for `GraphQLBoolean` <=> `{ type: 'boolean' }`. 482 | 483 | ```js 484 | app.register(mercuriusValidation, { 485 | schema: { 486 | Filters: { 487 | isAvailable: { type: 'boolean' } 488 | }, 489 | Query: { 490 | product: { 491 | id: { type: 'string', minLength: 1 } 492 | } 493 | } 494 | }, 495 | customTypeInferenceFn: (type, isNonNull) => { 496 | if (type === GraphQLBoolean) { 497 | return isNonNull ? { type: 'boolean' } : { type: ['boolean', 'null'] } 498 | } 499 | } 500 | }) 501 | ``` 502 | 503 | ## Caveats 504 | 505 | The use of the `$ref` keyword is not advised because we use this through the plugin to build up the GraphQL type validation. However, we have not prevented use of this keyword since it may be useful in some situations. 506 | -------------------------------------------------------------------------------- /docs/jtd-validation.md: -------------------------------------------------------------------------------- 1 | # JTD validation 2 | 3 | - [GraphQL argument validation](#graphql-argument-validation) 4 | - [GraphQL Input Object type field validation](#graphql-input-object-type-field-validation) 5 | - [GraphQL Input Object type validation](#graphql-input-object-type-validation) 6 | - [Additional AJV options](#additional-ajv-options) 7 | 8 | You can setup Mercurius validation to run in JTD mode when defining in-band validation schemas. It supports the following validation definitions: 9 | 10 | - Validation on GraphQL field arguments 11 | - Validation on Input Object type fields 12 | - Validation on Input Object types 13 | 14 | When defining validations for each of the above, any valid JTD keyword is supported. 15 | 16 | To enable JTD mode, set the `mode` options to `"JTD"` at registration: 17 | 18 | ```js 19 | app.register(mercuriusValidation, { 20 | mode: 'JTD', 21 | schema: { 22 | Query: { 23 | message: { 24 | id: { ... } // Any valid JTD definition 25 | } 26 | } 27 | } 28 | }) 29 | ``` 30 | 31 | ## GraphQL argument validation 32 | 33 | For the following GraphQL schema: 34 | 35 | ```gql 36 | type Query { 37 | message(id: ID): String 38 | } 39 | ``` 40 | 41 | You can define JTD validation on the `Query.message.id` argument as follows: 42 | 43 | ```js 44 | app.register(mercuriusValidation, { 45 | mode: 'JTD', 46 | schema: { 47 | Query: { 48 | message: { 49 | id: { ... } // Any valid JTD definition 50 | } 51 | } 52 | } 53 | }) 54 | ``` 55 | 56 | For example, if we wanted to check the ID input was a `int16` type: 57 | 58 | ```js 59 | app.register(mercuriusValidation, { 60 | mode: 'JTD', 61 | schema: { 62 | Query: { 63 | message: { 64 | id: { type: 'int16' } 65 | } 66 | } 67 | } 68 | }) 69 | ``` 70 | 71 | Upon failure(s), an example GraphQL response will look like: 72 | 73 | ```json 74 | { 75 | "data": { 76 | "message": null 77 | }, 78 | "errors": [ 79 | { 80 | "message": "Failed Validation on arguments for field 'Query.message'", 81 | "locations": [ 82 | { 83 | "line": 2, 84 | "column": 3 85 | } 86 | ], 87 | "path": [ 88 | "message" 89 | ], 90 | "extensions": { 91 | "code": "MER_VALIDATION_ERR_FAILED_VALIDATION", 92 | "name": "ValidationError", 93 | "details": [ 94 | { 95 | "instancePath": "/id", 96 | "schemaPath": "/optionalProperties/id/type", 97 | "keyword": "type", 98 | "params": { 99 | "type": "int16", 100 | "nullable": false 101 | }, 102 | "message": "must be int16", 103 | "schema": "int16", 104 | "parentSchema": { 105 | "type": "int16" 106 | }, 107 | "data": 32768 108 | } 109 | ] 110 | } 111 | } 112 | ] 113 | } 114 | ``` 115 | 116 | ## GraphQL Input Object type field validation 117 | 118 | For the following GraphQL schema: 119 | 120 | ```gql 121 | input Filters { 122 | text: String 123 | } 124 | 125 | type Query { 126 | messages(filters: Filters): String 127 | } 128 | ``` 129 | 130 | You can define JTD validation on the `Filters.text` input object type field as follows: 131 | 132 | ```js 133 | app.register(mercuriusValidation, { 134 | mode: 'JTD', 135 | schema: { 136 | Filters: { 137 | text: { ... } // Any valid JTD definition 138 | } 139 | } 140 | }) 141 | ``` 142 | 143 | For example, if we wanted to check the values text input were an enum: 144 | 145 | ```js 146 | app.register(mercuriusValidation, { 147 | mode: 'JTD', 148 | schema: { 149 | Filters: { 150 | text: { enum: ['hello', 'there'] } 151 | } 152 | } 153 | }) 154 | ``` 155 | 156 | Upon failure(s), an example GraphQL response will look like: 157 | 158 | ```json 159 | { 160 | "data": { 161 | "messages": null 162 | }, 163 | "errors": [ 164 | { 165 | "message": "Failed Validation on arguments for field 'Query.messages'", 166 | "locations": [{ 167 | "line": 2, 168 | "column": 7 169 | }], 170 | "path": [ 171 | "messages" 172 | ], 173 | "extensions": { 174 | "code": "MER_VALIDATION_ERR_FAILED_VALIDATION", 175 | "name": "ValidationError", 176 | "details": [ 177 | { 178 | "instancePath": "/filters/text", 179 | "schemaPath": "/definitions/Filters/optionalProperties/text/enum", 180 | "keyword": "enum", 181 | "params": { 182 | "allowedValues": [ 183 | "hello", 184 | "there" 185 | ] 186 | }, 187 | "message": "must be equal to one of the allowed values", 188 | "schema": [ 189 | "hello", 190 | "there" 191 | ], 192 | "parentSchema": { 193 | "enum": [ 194 | "hello", 195 | "there" 196 | ] 197 | }, 198 | "data": "wrong" 199 | } 200 | ] 201 | } 202 | } 203 | ] 204 | } 205 | ``` 206 | 207 | ## GraphQL Input Object type validation 208 | 209 | For the following GraphQL schema: 210 | 211 | ```gql 212 | input Filters { 213 | text: String 214 | } 215 | 216 | type Query { 217 | messages(filters: Filters): String 218 | } 219 | ``` 220 | 221 | You can define JTD validation on the `Filters` input object type using the reserved `__typeValidation` field as follows: 222 | 223 | ```js 224 | app.register(mercuriusValidation, { 225 | mode: 'JTD', 226 | schema: { 227 | Filters: { 228 | __typeValidation: { ... } // Any valid JTD definition 229 | } 230 | } 231 | }) 232 | ``` 233 | 234 | For example, if we wanted to check the Filters input object type values are all `unit8`: 235 | 236 | ```js 237 | app.register(mercuriusValidation, { 238 | mode: 'JTD', 239 | schema: { 240 | Filters: { 241 | __typeValidation: { 242 | values: { 243 | type: 'uint8' 244 | } 245 | } 246 | } 247 | } 248 | }) 249 | ``` 250 | 251 | Upon failure(s), an example GraphQL response will look like: 252 | 253 | ```json 254 | { 255 | "data": { 256 | "messages": null 257 | }, 258 | "errors": [ 259 | { 260 | "message": "Failed Validation on arguments for field 'Query.messages'", 261 | "locations": [{ 262 | "line": 2, 263 | "column": 7 264 | }], 265 | "path": [ 266 | "messages" 267 | ], 268 | "extensions": { 269 | "code": "MER_VALIDATION_ERR_FAILED_VALIDATION", 270 | "name": "ValidationError", 271 | "details": [ 272 | { 273 | "instancePath": "/filters/id", 274 | "schemaPath": "/definitions/Filters/values/type", 275 | "keyword": "type", 276 | "params": { 277 | "type": "uint8", 278 | "nullable": false 279 | }, 280 | "message": "must be uint8", 281 | "schema": "uint8", 282 | "parentSchema": { 283 | "type": "uint8" 284 | }, 285 | "data": "256" 286 | } 287 | ] 288 | } 289 | } 290 | ] 291 | } 292 | ``` 293 | 294 | ## Additional AJV options 295 | 296 | If you need to provide additional AJV options, such setting `allErrors` to `false`, we can provide these at plugin registration: 297 | 298 | For the schema: 299 | 300 | ```gql 301 | type Query { 302 | message(id: ID): String 303 | } 304 | ``` 305 | 306 | Registration: 307 | 308 | ```js 309 | app.register(mercuriusValidation, { 310 | mode: 'JTD', 311 | allErrors: false, 312 | schema: { 313 | Query: { 314 | message: { 315 | id: { type: 'int16' } 316 | } 317 | } 318 | } 319 | }) 320 | ``` 321 | -------------------------------------------------------------------------------- /docs/registration.md: -------------------------------------------------------------------------------- 1 | # Registration 2 | 3 | The `mercurius-validation` plugin must be registered **after** Mercurius is registered. 4 | 5 | ```js 6 | 'use strict' 7 | 8 | const Fastify = require('fastify') 9 | const mercurius = require('mercurius') 10 | const mercuriusValidation = require('mercurius-validation') 11 | 12 | const app = Fastify() 13 | 14 | const schema = ` 15 | directive @validation( 16 | requires: Role = ADMIN, 17 | ) on OBJECT | FIELD_DEFINITION 18 | 19 | enum Role { 20 | ADMIN 21 | REVIEWER 22 | USER 23 | UNKNOWN 24 | } 25 | 26 | type Query { 27 | add(x: Int, y: Int): Int @validation(requires: USER) 28 | } 29 | ` 30 | 31 | const resolvers = { 32 | Query: { 33 | add: async (_, { x, y }) => x + y 34 | } 35 | } 36 | 37 | app.register(mercurius, { 38 | schema, 39 | resolvers 40 | }) 41 | 42 | // After initial setup, register Mercurius validation 43 | app.register(mercuriusValidation, { 44 | validationContext (context) { 45 | return { 46 | identity: context.reply.request.headers['x-user'] 47 | } 48 | }, 49 | async applyPolicy (validationDirectiveAST, parent, args, context, info) { 50 | return context.validation.identity === 'admin' 51 | }, 52 | validationDirective: 'validation' 53 | }) 54 | 55 | app.listen({ port: 3000 }) 56 | ``` 57 | -------------------------------------------------------------------------------- /examples/directive-validation.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Fastify = require('fastify') 4 | const mercurius = require('mercurius') 5 | const mercuriusValidation = require('mercurius-validation') 6 | 7 | const schema = ` 8 | ${mercuriusValidation.graphQLTypeDefs} 9 | 10 | type Message { 11 | id: ID! 12 | text: String 13 | } 14 | 15 | input Filters { 16 | id: ID 17 | text: String @constraint(minLength: 1) 18 | } 19 | 20 | type Query { 21 | message(id: ID @constraint(type: "string", minLength: 1)): Message 22 | messages(filters: Filters): [Message] 23 | } 24 | ` 25 | 26 | const messages = [ 27 | { 28 | id: 0, 29 | text: 'Some system message.' 30 | }, 31 | { 32 | id: 1, 33 | text: 'Hello there' 34 | }, 35 | { 36 | id: 2, 37 | text: 'Give me a place to stand, a lever long enough and a fulcrum. And I can move the Earth.' 38 | }, 39 | { 40 | id: 3, 41 | text: '' 42 | } 43 | ] 44 | 45 | const resolvers = { 46 | Query: { 47 | message: async (_, { id }) => { 48 | return messages.find(message => message.id === Number(id)) 49 | }, 50 | messages: async () => { 51 | return messages 52 | } 53 | } 54 | } 55 | 56 | const app = Fastify() 57 | app.register(mercurius, { 58 | schema, 59 | resolvers 60 | }) 61 | app.register(mercuriusValidation) 62 | 63 | app.listen({ port: 3000 }) 64 | -------------------------------------------------------------------------------- /examples/gateway.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Fastify = require('fastify') 4 | const mercurius = require('mercurius') 5 | const mercuriusValidation = require('mercurius-validation') 6 | 7 | async function createService (schema, resolvers = {}) { 8 | const service = Fastify() 9 | service.register(mercurius, { 10 | schema, 11 | resolvers, 12 | federationMetadata: true 13 | }) 14 | await service.listen({ port: 0 }) 15 | return [service, service.server.address().port] 16 | } 17 | 18 | const users = { 19 | u1: { 20 | id: 'u1', 21 | name: 'John' 22 | }, 23 | u2: { 24 | id: 'u2', 25 | name: 'Jane' 26 | } 27 | } 28 | 29 | const posts = { 30 | p1: { 31 | pid: 'p1', 32 | title: 'Post 1', 33 | content: 'Content 1', 34 | authorId: 'u1' 35 | }, 36 | p2: { 37 | pid: 'p2', 38 | title: 'Post 2', 39 | content: 'Content 2', 40 | authorId: 'u2' 41 | }, 42 | p3: { 43 | pid: 'p3', 44 | title: 'Post 3', 45 | content: 'Content 3', 46 | authorId: 'u1' 47 | }, 48 | p4: { 49 | pid: 'p4', 50 | title: 'Post 4', 51 | content: 'Content 4', 52 | authorId: 'u1' 53 | } 54 | } 55 | 56 | async function start (authOpts) { 57 | // User service 58 | const userServiceSchema = ` 59 | type Query @extends { 60 | me(id: Int): User 61 | } 62 | 63 | type User @key(fields: "id") { 64 | id: ID! 65 | name: String 66 | }` 67 | const userServiceResolvers = { 68 | Query: { 69 | me: (root, args, context, info) => { 70 | return users.u1 71 | } 72 | }, 73 | User: { 74 | __resolveReference: (user, args, context, info) => { 75 | return users[user.id] 76 | } 77 | } 78 | } 79 | const [, userServicePort] = await createService(userServiceSchema, userServiceResolvers) 80 | 81 | // Post service 82 | const postServiceSchema = ` 83 | type Post @key(fields: "pid") { 84 | pid: ID! 85 | author: User 86 | } 87 | 88 | extend type Query { 89 | topPosts(count: Int): [Post] 90 | } 91 | 92 | type User @key(fields: "id") @extends { 93 | id: ID! @external 94 | topPosts(count: Int!): [Post] 95 | }` 96 | const postServiceResolvers = { 97 | Post: { 98 | __resolveReference: (post, args, context, info) => { 99 | return posts[post.pid] 100 | }, 101 | author: (post, args, context, info) => { 102 | return { 103 | __typename: 'User', 104 | id: post.authorId 105 | } 106 | } 107 | }, 108 | User: { 109 | topPosts: (user, { count }, context, info) => { 110 | return Object.values(posts).filter(p => p.authorId === user.id).slice(0, count) 111 | } 112 | }, 113 | Query: { 114 | topPosts: (root, { count = 2 }) => Object.values(posts).slice(0, count) 115 | } 116 | } 117 | const [, postServicePort] = await createService(postServiceSchema, postServiceResolvers) 118 | 119 | const gateway = Fastify() 120 | 121 | gateway.register(mercurius, { 122 | gateway: { 123 | services: [{ 124 | name: 'user', 125 | url: `http://127.0.0.1:${userServicePort}/graphql` 126 | }, { 127 | name: 'post', 128 | url: `http://127.0.0.1:${postServicePort}/graphql` 129 | }] 130 | } 131 | }) 132 | 133 | gateway.register(mercuriusValidation, { 134 | schema: { 135 | User: { 136 | topPosts: { 137 | count: { type: 'integer', minimum: 1 } 138 | } 139 | }, 140 | Query: { 141 | me: { 142 | id: { type: 'integer', minimum: 1 } 143 | }, 144 | topPosts: { 145 | count: { type: 'integer', minimum: 1 } 146 | } 147 | } 148 | } 149 | }) 150 | 151 | await gateway.listen({ port: 3000 }) 152 | } 153 | 154 | start() 155 | -------------------------------------------------------------------------------- /examples/json-schema-validation.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Fastify = require('fastify') 4 | const mercurius = require('mercurius') 5 | const mercuriusValidation = require('mercurius-validation') 6 | 7 | const schema = ` 8 | type Message { 9 | id: ID! 10 | text: String 11 | } 12 | 13 | input Filters { 14 | id: ID 15 | text: String 16 | } 17 | 18 | input NestedFilters { 19 | input: Filters 20 | } 21 | 22 | input ArrayFilters { 23 | values: [String] 24 | filters: [Filters] 25 | } 26 | 27 | type Query { 28 | message(id: ID): Message 29 | messages(filters: Filters): [Message] 30 | } 31 | ` 32 | 33 | const messages = [ 34 | { 35 | id: 0, 36 | text: 'Some system message.' 37 | }, 38 | { 39 | id: 1, 40 | text: 'Hello there' 41 | }, 42 | { 43 | id: 2, 44 | text: 'Give me a place to stand, a lever long enough and a fulcrum. And I can move the Earth.' 45 | }, 46 | { 47 | id: 3, 48 | text: '' 49 | } 50 | ] 51 | 52 | const resolvers = { 53 | Query: { 54 | message: async (_, { id }) => { 55 | return messages.find(message => message.id === Number(id)) 56 | }, 57 | messages: async () => { 58 | return messages 59 | } 60 | } 61 | } 62 | 63 | const app = Fastify() 64 | app.register(mercurius, { 65 | schema, 66 | resolvers 67 | }) 68 | app.register(mercuriusValidation, { 69 | schema: { 70 | Filters: { 71 | text: { type: 'string', minLength: 1 } 72 | }, 73 | Query: { 74 | message: { 75 | id: { type: 'string', minLength: 1 } 76 | } 77 | } 78 | } 79 | }) 80 | 81 | app.listen({ port: 3000 }) 82 | -------------------------------------------------------------------------------- /examples/jtd-validation.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Fastify = require('fastify') 4 | const mercurius = require('mercurius') 5 | const mercuriusValidation = require('mercurius-validation') 6 | 7 | const schema = ` 8 | type Message { 9 | id: ID! 10 | text: String 11 | } 12 | 13 | input Filters { 14 | id: ID 15 | text: String 16 | } 17 | 18 | input NestedFilters { 19 | input: Filters 20 | } 21 | 22 | input ArrayFilters { 23 | values: [String] 24 | filters: [Filters] 25 | } 26 | 27 | type Query { 28 | message(id: ID): Message 29 | messages(filters: Filters): [Message] 30 | } 31 | ` 32 | 33 | const messages = [ 34 | { 35 | id: 0, 36 | text: 'Some system message.' 37 | }, 38 | { 39 | id: 1, 40 | text: 'Hello there' 41 | }, 42 | { 43 | id: 2, 44 | text: 'Give me a place to stand, a lever long enough and a fulcrum. And I can move the Earth.' 45 | }, 46 | { 47 | id: 3, 48 | text: '' 49 | } 50 | ] 51 | 52 | const resolvers = { 53 | Query: { 54 | message: async (_, { id }) => { 55 | return messages.find(message => message.id === Number(id)) 56 | }, 57 | messages: async () => { 58 | return messages 59 | } 60 | } 61 | } 62 | 63 | const app = Fastify() 64 | app.register(mercurius, { 65 | schema, 66 | resolvers 67 | }) 68 | app.register(mercuriusValidation, { 69 | mode: 'JTD', 70 | schema: { 71 | Filters: { 72 | text: { enum: ['hello', 'there'] } 73 | }, 74 | Query: { 75 | message: { 76 | id: { type: 'int16' } 77 | } 78 | } 79 | } 80 | }) 81 | 82 | app.listen({ port: 3000 }) 83 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | import { FastifyInstance } from 'fastify' 2 | import { GraphQLDirective, GraphQLResolveInfo } from 'graphql' 3 | import { MercuriusContext } from 'mercurius' 4 | import { Options, SchemaObject } from 'ajv' 5 | 6 | /** 7 | * GraphQL metadata associated with the argument validation. 8 | */ 9 | export interface MercuriusValidationHandlerMetadata { 10 | /** 11 | * The name of the associated GraphQL type. 12 | */ 13 | type: string; 14 | /** 15 | * The name of the associated GraphQL field. 16 | */ 17 | field: string; 18 | /** 19 | * The name of the associated GraphQL argument. 20 | */ 21 | argument: string; 22 | } 23 | 24 | /** 25 | * The validation function to run when an argument is selected by the GraphQL operation. 26 | * Thrown errors will be caught and handled by `mercurius-validation`. 27 | */ 28 | export type MercuriusValidationHandler = ( 29 | metadata: MercuriusValidationHandlerMetadata, 30 | value: any, 31 | parent: TParent, 32 | args: TArgs, 33 | context: TContext, 34 | info: GraphQLResolveInfo 35 | ) => Promise; 36 | 37 | /** 38 | * Mercurius Validation argument definition. Accepts JSON Schema, JTD or custom function. 39 | */ 40 | export type MercuriusValidationArgument = | 41 | SchemaObject | 42 | MercuriusValidationHandler; 43 | 44 | /** 45 | * Mercurius Validation field definition. Accepts argument definition, JSON Schema or JTD. 46 | */ 47 | export type MercuriusValidationField = Record> | SchemaObject 48 | 49 | /** 50 | * Mercurius Validation type definition. Accepts field definition and/or type validation definition. 51 | */ 52 | export type MercuriusValidationType = Record> & { 53 | /** 54 | * Define a validation schema here to validate the GraphQL input object type. 55 | */ 56 | __typeValidation?: SchemaObject 57 | } 58 | 59 | /** 60 | * Mercurius Validation schema. Each key corresponds to a GraphQL type name. 61 | */ 62 | export type MercuriusValidationSchema = Record> 63 | 64 | /** 65 | * The modes of operation available when interpreting Mercurius validation schemas. 66 | */ 67 | export type MercuriusValidationMode = 'JSONSchema' | 'JTD' 68 | 69 | /** 70 | * Mercurius validation options. 71 | */ 72 | export interface MercuriusValidationOptions extends Options { 73 | /** 74 | * The mode of operation to use when interpreting Mercurius validation schemas (default: `"JSONSchema"`). 75 | */ 76 | mode?: MercuriusValidationMode; 77 | /** 78 | * The validation schema definition for the Mercurius GraphQL server. 79 | */ 80 | schema?: MercuriusValidationSchema; 81 | /** 82 | * Turn directive validation on or off (default: `true`). 83 | */ 84 | directiveValidation?: boolean; 85 | } 86 | 87 | export default MercuriusValidation 88 | 89 | /** Mercurius Validation is a plugin for `mercurius` that adds configurable validation support. */ 90 | declare function MercuriusValidation ( 91 | instance: FastifyInstance, 92 | opts: MercuriusValidationOptions 93 | ): void; 94 | 95 | declare namespace MercuriusValidation { 96 | export const graphQLTypeDefs: string 97 | export const graphQLDirective: GraphQLDirective 98 | } 99 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const fp = require('fastify-plugin') 4 | const Validation = require('./lib/validation') 5 | const { validateOpts } = require('./lib/utils') 6 | const { validationDirective, validationTypeDefs } = require('./lib/directive') 7 | 8 | const mercuriusValidation = fp( 9 | async function (app, opts) { 10 | // Validate options 11 | const validatedOpts = validateOpts(opts) 12 | 13 | // Start validation and register hooks 14 | const validation = new Validation(app, validatedOpts) 15 | 16 | validation.registerValidationSchema(app.graphql.schema) 17 | 18 | // Add hook to regenerate the resolvers when the schema is refreshed 19 | if (app.graphqlGateway) { 20 | app.graphqlGateway.addHook('onGatewayReplaceSchema', async (instance, schema) => { 21 | validation.registerValidationSchema(schema) 22 | }) 23 | } 24 | }, 25 | { 26 | name: 'mercurius-validation', 27 | fastify: '5.x', 28 | dependencies: ['mercurius'] 29 | } 30 | ) 31 | 32 | mercuriusValidation.graphQLDirective = validationDirective 33 | mercuriusValidation.graphQLTypeDefs = validationTypeDefs 34 | 35 | module.exports = mercuriusValidation 36 | -------------------------------------------------------------------------------- /lib/directive.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { 4 | GraphQLDirective, 5 | DirectiveLocation, 6 | GraphQLString, 7 | GraphQLSchema, 8 | printSchema, 9 | GraphQLBoolean, 10 | GraphQLInt, 11 | GraphQLList, 12 | GraphQLNonNull 13 | } = require('graphql') 14 | const Ajv = require('ajv') 15 | 16 | function buildConstraintDirective () { 17 | const ajv = new Ajv() 18 | 19 | const locations = [ 20 | DirectiveLocation.ARGUMENT_DEFINITION, 21 | DirectiveLocation.INPUT_FIELD_DEFINITION, 22 | DirectiveLocation.INPUT_OBJECT 23 | ] 24 | 25 | const args = {} 26 | // We only support the following JSON Schema types at the moment 27 | const typeMapper = { 28 | boolean: GraphQLBoolean, // Type: Boolean 29 | string: GraphQLString, // Type: String 30 | number: GraphQLInt, // Type: Int 31 | array: new GraphQLList(new GraphQLNonNull(GraphQLString)), // Type: [String!] 32 | 'string.array': new GraphQLList(new GraphQLNonNull(GraphQLString)) // Type: [String!] 33 | } 34 | 35 | const allowedKeywords = { 36 | type: 'type.html', 37 | maxLength: 'string.html#length', 38 | minLength: 'string.html#length', 39 | format: 'string.html#format', 40 | pattern: 'string.html#pattern', 41 | maximum: 'numeric.html#range', 42 | minimum: 'numeric.html#range', 43 | exclusiveMaximum: 'numeric.html#range', 44 | exclusiveMinimum: 'numeric.html#range', 45 | multipleOf: 'numeric.html#multiples', 46 | maxProperties: 'object.html#size', 47 | minProperties: 'object.html#size', 48 | required: 'object.html#required-properties', 49 | maxItems: 'array.html#length', 50 | minItems: 'array.html#length', 51 | uniqueItems: 'array.html#uniqueness' 52 | } 53 | 54 | for (const { keyword, definition } of Object.values(ajv.RULES.all)) { 55 | const parsedSchemaType = definition.schemaType.join('.') 56 | const type = typeMapper[parsedSchemaType] 57 | const allowedKeywordDocs = allowedKeywords[keyword] 58 | if (typeof allowedKeywordDocs === 'string' && typeof type !== 'undefined') { 59 | const [associatedType] = definition.type 60 | let associatedTypeDescription = '' 61 | if (typeof associatedType !== 'undefined') { 62 | associatedTypeDescription = ` for '${associatedType}' types` 63 | } 64 | const docsReference = `https://json-schema.org/understanding-json-schema/reference/${allowedKeywordDocs}` 65 | const description = `JSON Schema '${keyword}' keyword${associatedTypeDescription}. Reference: ${docsReference}.` 66 | args[keyword] = { 67 | description, 68 | type 69 | } 70 | } 71 | } 72 | 73 | // Add schema argument to pass custom schemas not yet supported by the directive definitions. 74 | args.schema = { 75 | type: GraphQLString, 76 | description: 'The "schema" argument is used to pass custom JSON Schemas with keywords and definitions that are not yet supported by the directive definitions.' 77 | } 78 | 79 | const directive = new GraphQLDirective({ 80 | name: 'constraint', 81 | args, 82 | locations, 83 | description: 'JSON Schema constraint directive.' 84 | }) 85 | 86 | const schema = new GraphQLSchema({ 87 | directives: [directive] 88 | }) 89 | 90 | return [directive, printSchema(schema)] 91 | } 92 | 93 | const [validationDirective, validationTypeDefs] = buildConstraintDirective() 94 | 95 | module.exports = { 96 | validationDirective, 97 | validationTypeDefs 98 | } 99 | -------------------------------------------------------------------------------- /lib/errors.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const createError = require('@fastify/error') 4 | 5 | class ValidationError extends Error { 6 | constructor (message, details) { 7 | super(message) 8 | this.name = 'ValidationError' 9 | this.code = 'MER_VALIDATION_ERR_FAILED_VALIDATION' 10 | this.extensions = { 11 | name: this.name, 12 | code: this.code, 13 | details 14 | } 15 | this.statusCode = 400 16 | ValidationError.prototype[Symbol.toStringTag] = 'Error' 17 | ValidationError.prototype.toString = function () { 18 | return `${this.name} [${this.code}]: ${this.message}` 19 | } 20 | } 21 | } 22 | 23 | const errors = { 24 | /** 25 | * Options validation errors 26 | */ 27 | MER_VALIDATION_ERR_INVALID_OPTS: createError( 28 | 'MER_VALIDATION_ERR_INVALID_OPTS', 29 | 'Invalid options: %s' 30 | ), 31 | /** 32 | * Registration errors 33 | */ 34 | MER_VALIDATION_ERR_FIELD_TYPE_UNDEFINED: createError( 35 | 'MER_VALIDATION_ERR_FIELD_TYPE_UNDEFINED', 36 | 'Type of field must be defined: %s' 37 | ), 38 | /** 39 | * Validation errors 40 | */ 41 | MER_VALIDATION_ERR_FAILED_VALIDATION: ValidationError 42 | } 43 | 44 | module.exports = errors 45 | -------------------------------------------------------------------------------- /lib/symbols.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = { 4 | kApp: Symbol('app instance'), 5 | kOpts: Symbol('opts'), 6 | kAjv: Symbol('ajv instance'), 7 | kValidators: Symbol('validators'), 8 | kMakeResolver: Symbol('make resolver'), 9 | kOverrideFieldResolver: Symbol('override field resolver'), 10 | kValidationSchema: Symbol('validation schema'), 11 | kBuildValidationSchema: Symbol('build validation schema'), 12 | kBuildValidationSchemaFromDirective: Symbol('build validation schema from directive'), 13 | kSetArgumentValidationSchema: Symbol('set argument validation schema'), 14 | kSetFieldValidationSchema: Symbol('set field validation schema'), 15 | kSetInputObjectTypeValidationSchema: Symbol('set input object type validation schema'), 16 | kJsonSchemaValidator: Symbol('json schema validator'), 17 | kValidationDirective: Symbol('validation directive'), 18 | kGetValidationDirectiveAST: Symbol('get validation directive ast'), 19 | kDirectiveValidator: Symbol('directive validator'), 20 | kSchemaValidator: Symbol('schema validator'), 21 | kFunctionValidator: Symbol('function validator'), 22 | kBuildArgumentsSchema: Symbol('build arguments validation schema'), 23 | kBuildInputTypeFieldSchema: Symbol('build input type field validation schema') 24 | } 25 | -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { isNonNullType, getNamedType, GraphQLString, GraphQLInt, GraphQLFloat } = require('graphql') 4 | const { MER_VALIDATION_ERR_INVALID_OPTS } = require('./errors') 5 | 6 | /** 7 | * Perform basic validation on the validation options. 8 | */ 9 | function validateOpts (opts) { 10 | if (typeof opts.mode !== 'undefined' && typeof opts.mode !== 'string') { 11 | throw new MER_VALIDATION_ERR_INVALID_OPTS('opts.mode must be a string.') 12 | } 13 | 14 | if (typeof opts.directiveValidation !== 'undefined' && typeof opts.directiveValidation !== 'boolean') { 15 | throw new MER_VALIDATION_ERR_INVALID_OPTS('opts.directiveValidation must be a boolean.') 16 | } 17 | 18 | if (typeof opts.schema !== 'undefined') { 19 | if (typeof opts.schema !== 'object' || opts.schema === null) { 20 | throw new MER_VALIDATION_ERR_INVALID_OPTS('opts.schema must be an object.') 21 | } 22 | 23 | for (const [typeName, type] of Object.entries(opts.schema)) { 24 | if (typeof type !== 'object' || type === null) { 25 | throw new MER_VALIDATION_ERR_INVALID_OPTS(`opts.schema.${typeName} must be an object.`) 26 | } 27 | 28 | for (const [fieldName, field] of Object.entries(type)) { 29 | if (typeof field !== 'object' || field === null) { 30 | throw new MER_VALIDATION_ERR_INVALID_OPTS(`opts.schema.${typeName}.${fieldName} cannot be a function. Only field arguments currently support functional validators.`) 31 | } 32 | } 33 | } 34 | } 35 | return opts 36 | } 37 | 38 | function inferJSONSchemaType (type, isNonNull, customTypeInferenceFn) { 39 | if (customTypeInferenceFn) { 40 | const customResponse = customTypeInferenceFn(type, isNonNull) 41 | if (customResponse) { 42 | return customResponse 43 | } 44 | } 45 | if (type === GraphQLString) { 46 | return isNonNull ? { type: 'string' } : { type: ['string', 'null'] } 47 | } 48 | if (type === GraphQLInt) { 49 | return isNonNull ? { type: 'integer' } : { type: ['integer', 'null'] } 50 | } 51 | if (type === GraphQLFloat) { 52 | return isNonNull ? { type: 'number' } : { type: ['number', 'null'] } 53 | } 54 | return {} 55 | } 56 | 57 | function getTypeInfo (graphQLType) { 58 | const isNonNull = isNonNullType(graphQLType.type) 59 | const type = isNonNull ? graphQLType.type.ofType : graphQLType.type 60 | return [type, getNamedType(type), isNonNull] 61 | } 62 | 63 | module.exports = { 64 | validateOpts, 65 | getTypeInfo, 66 | inferJSONSchemaType 67 | } 68 | -------------------------------------------------------------------------------- /lib/validation.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { FunctionValidator, JSONSchemaValidator, JTDValidator, DirectiveValidator } = require('./validators') 4 | const { kValidationSchema, kDirectiveValidator, kSchemaValidator, kFunctionValidator } = require('./symbols') 5 | 6 | class Validation { 7 | constructor (app, { schema, mode = 'JSONSchema', directiveValidation = true, ...opts }) { 8 | this[kValidationSchema] = schema || {} 9 | 10 | // By default, we turn on directive validation 11 | if (directiveValidation) { 12 | this[kDirectiveValidator] = new DirectiveValidator(opts) 13 | } else { 14 | this[kDirectiveValidator] = null 15 | } 16 | 17 | if (mode === 'JTD') { 18 | this[kSchemaValidator] = new JTDValidator(opts) 19 | } else { 20 | this[kSchemaValidator] = new JSONSchemaValidator(opts) 21 | } 22 | 23 | this[kFunctionValidator] = new FunctionValidator(app, opts) 24 | } 25 | 26 | // We are just considering inputs and functions on arguments. We are not considering: 27 | // - Functions on input type fields 28 | // - Validation on field responses 29 | registerValidationSchema (graphQLSchema) { 30 | // We register policies in the reverse order of intended operation 31 | if (this[kDirectiveValidator] !== null) { 32 | this[kDirectiveValidator].registerValidationSchema(graphQLSchema) 33 | } 34 | this[kFunctionValidator].registerValidationSchema(graphQLSchema, this[kValidationSchema]) 35 | this[kSchemaValidator].registerValidationSchema(graphQLSchema, this[kValidationSchema]) 36 | } 37 | } 38 | 39 | module.exports = Validation 40 | -------------------------------------------------------------------------------- /lib/validators/directive-validator.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { isInputObjectType, valueFromASTUntyped } = require('graphql') 4 | const JSONSchemaValidator = require('./json-schema-validator') 5 | const { validationDirective } = require('../directive') 6 | const { 7 | kOpts, 8 | kGetValidationDirectiveAST, 9 | kValidationDirective, 10 | kJsonSchemaValidator, 11 | kBuildValidationSchema, 12 | kSetFieldValidationSchema, 13 | kSetArgumentValidationSchema, 14 | kSetInputObjectTypeValidationSchema, 15 | kBuildValidationSchemaFromDirective 16 | } = require('../symbols') 17 | 18 | class DirectiveValidator { 19 | constructor (opts) { 20 | this[kValidationDirective] = validationDirective.name 21 | this[kOpts] = opts 22 | } 23 | 24 | [kGetValidationDirectiveAST] (astNode) { 25 | if (typeof astNode !== 'undefined' && Array.isArray(astNode.directives) && astNode.directives.length > 0) { 26 | const validationDirective = astNode.directives.find( 27 | (directive) => directive.name.value === this[kValidationDirective] 28 | ) 29 | if (typeof validationDirective !== 'undefined') { 30 | return validationDirective 31 | } 32 | } 33 | return null 34 | } 35 | 36 | [kSetInputObjectTypeValidationSchema] (validationSchema, typeName, typeValidation) { 37 | // This is never going to be defined because it is always the first check for a type 38 | validationSchema[typeName] = { __typeValidation: typeValidation } 39 | return validationSchema 40 | } 41 | 42 | [kSetFieldValidationSchema] (validationSchema, typeName, fieldName, fieldValidation) { 43 | const typeValidationSchema = validationSchema[typeName] 44 | if (typeof typeValidationSchema === 'object') { 45 | typeValidationSchema[fieldName] = fieldValidation 46 | } else { 47 | validationSchema[typeName] = { 48 | [fieldName]: fieldValidation 49 | } 50 | } 51 | return validationSchema 52 | } 53 | 54 | [kSetArgumentValidationSchema] (validationSchema, typeName, fieldName, argumentName, argumentValidation) { 55 | const typeValidationSchema = validationSchema[typeName] 56 | if (typeof typeValidationSchema === 'object') { 57 | const fieldValidationSchema = typeValidationSchema[fieldName] 58 | if (typeof fieldValidationSchema === 'object') { 59 | fieldValidationSchema[argumentName] = argumentValidation 60 | } else { 61 | typeValidationSchema[fieldName] = { [argumentName]: argumentValidation } 62 | } 63 | } else { 64 | validationSchema[typeName] = { 65 | [fieldName]: { [argumentName]: argumentValidation } 66 | } 67 | } 68 | return validationSchema 69 | } 70 | 71 | [kBuildValidationSchemaFromDirective] (directiveAST) { 72 | let validationSchema = {} 73 | for (const argument of directiveAST.arguments) { 74 | // If custom schema argument, merge with existing validationSchema 75 | const value = valueFromASTUntyped(argument.value) 76 | if (argument.name.value === 'schema') { 77 | validationSchema = { ...validationSchema, ...JSON.parse(value) } 78 | } else { 79 | validationSchema[argument.name.value] = value 80 | } 81 | } 82 | return validationSchema 83 | } 84 | 85 | [kBuildValidationSchema] (graphQLSchema) { 86 | let validationSchema = {} 87 | for (const [typeName, type] of Object.entries(graphQLSchema.getTypeMap())) { 88 | if (!typeName.startsWith('__') && typeof type.getFields === 'function') { 89 | if (isInputObjectType(type)) { 90 | const inputObjectTypeDirectiveAST = this[kGetValidationDirectiveAST](type.astNode) 91 | if (inputObjectTypeDirectiveAST !== null) { 92 | const typeValidationSchema = this[kBuildValidationSchemaFromDirective](inputObjectTypeDirectiveAST) 93 | validationSchema = this[kSetInputObjectTypeValidationSchema](validationSchema, typeName, typeValidationSchema) 94 | } 95 | } 96 | for (const [fieldName, field] of Object.entries(type.getFields())) { 97 | if (typeof field.args !== 'undefined' && Object.keys(field.args).length > 0) { 98 | for (const argument of field.args) { 99 | const argumentDirectiveAST = this[kGetValidationDirectiveAST](argument.astNode) 100 | if (argumentDirectiveAST !== null) { 101 | const argumentValidationSchema = this[kBuildValidationSchemaFromDirective](argumentDirectiveAST) 102 | validationSchema = this[kSetArgumentValidationSchema]( 103 | validationSchema, 104 | typeName, 105 | fieldName, 106 | argument.name, 107 | argumentValidationSchema 108 | ) 109 | } 110 | } 111 | } else if (isInputObjectType(type)) { 112 | const fieldDirectiveAST = this[kGetValidationDirectiveAST](field.astNode) 113 | if (fieldDirectiveAST !== null) { 114 | const fieldValidationSchema = this[kBuildValidationSchemaFromDirective](fieldDirectiveAST) 115 | validationSchema = this[kSetFieldValidationSchema]( 116 | validationSchema, 117 | typeName, 118 | fieldName, 119 | fieldValidationSchema 120 | ) 121 | } 122 | } 123 | } 124 | } 125 | } 126 | return validationSchema 127 | } 128 | 129 | registerValidationSchema (graphQLSchema) { 130 | // Instantiated here to make sure it is reset after a gateway schema refresh 131 | this[kJsonSchemaValidator] = new JSONSchemaValidator(this[kOpts]) 132 | 133 | // If the schema includes the validation directive, set up the JSON Schema validation 134 | if (graphQLSchema.getDirectives().some(directive => directive.name === this[kValidationDirective])) { 135 | const validationSchema = this[kBuildValidationSchema](graphQLSchema) 136 | this[kJsonSchemaValidator].registerValidationSchema(graphQLSchema, validationSchema) 137 | } 138 | } 139 | } 140 | 141 | module.exports = DirectiveValidator 142 | -------------------------------------------------------------------------------- /lib/validators/function-validator.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { MER_VALIDATION_ERR_FAILED_VALIDATION } = require('../errors') 4 | const { kOpts, kApp, kValidators, kMakeResolver } = require('../symbols') 5 | 6 | class FunctionValidator { 7 | constructor (app, opts) { 8 | this[kApp] = app 9 | this[kOpts] = opts 10 | } 11 | 12 | [kMakeResolver] (type, field, resolverFn) { 13 | return async (parent, args, context, info) => { 14 | const results = await Promise.allSettled(Object.entries(args).map(([argument, argumentValue]) => { 15 | const validate = this[kValidators].get(`${type}.${field}.${argument}`) 16 | if (typeof validate !== 'function') { 17 | return null 18 | } 19 | return validate({ type, field, argument }, argumentValue, parent, args, context, info) 20 | })) 21 | 22 | const errors = results.filter(result => result.status === 'rejected').map(result => result.reason) 23 | if (errors.length > 0) { 24 | throw new MER_VALIDATION_ERR_FAILED_VALIDATION(`Failed Validation on arguments for field '${type}.${field}'`, errors) 25 | } 26 | 27 | return resolverFn(parent, args, context, info) 28 | } 29 | } 30 | 31 | registerValidationSchema (schema, validationSchema) { 32 | // Instantiated here to make sure it is reset after a gateway schema refresh 33 | this[kValidators] = new Map() 34 | 35 | for (const [typeName, typeSchema] of Object.entries(validationSchema)) { 36 | const schemaType = schema.getType(typeName) 37 | if (typeof schemaType !== 'undefined') { 38 | for (const [fieldName, fieldSchema] of Object.entries(typeSchema)) { 39 | const schemaTypeField = schemaType.getFields()[fieldName] 40 | if (typeof schemaTypeField !== 'undefined') { 41 | let override = false 42 | if (typeof schemaTypeField.args !== 'undefined') { 43 | for (const [argumentName, argumentFn] of Object.entries(fieldSchema)) { 44 | const schemaArgument = schemaTypeField.args.find(argument => argument.name === argumentName) 45 | if (typeof schemaArgument !== 'undefined') { 46 | if (typeof argumentFn === 'function') { 47 | override = true 48 | this[kValidators].set(`${typeName}.${fieldName}.${argumentName}`, argumentFn) 49 | } 50 | } else { 51 | this[kApp].log.warn(`No GraphQL schema argument with key '${typeName}.${fieldName}.${argumentName}' found. Validation function will not be run.`) 52 | } 53 | } 54 | } 55 | // Overwrite field resolver. 56 | // Because we are only considering running validator on arguments 57 | // This types will always have a resolve function 58 | if (override && typeof schemaTypeField.resolve === 'function') { 59 | const originalFieldResolver = schemaTypeField.resolve 60 | schemaTypeField.resolve = this[kMakeResolver](typeName, fieldName, originalFieldResolver) 61 | } 62 | } else { 63 | this[kApp].log.warn(`No GraphQL schema field with key '${typeName}.${fieldName}' found. Validation functions will not be run.`) 64 | } 65 | } 66 | } else { 67 | this[kApp].log.warn(`No GraphQL schema type with key '${typeName}' found. Validation functions will not be run.`) 68 | } 69 | } 70 | } 71 | } 72 | 73 | module.exports = FunctionValidator 74 | -------------------------------------------------------------------------------- /lib/validators/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const DirectiveValidator = require('./directive-validator') 4 | const FunctionValidator = require('./function-validator') 5 | const JSONSchemaValidator = require('./json-schema-validator') 6 | const JTDValidator = require('./jtd-validator') 7 | 8 | module.exports = { 9 | DirectiveValidator, 10 | FunctionValidator, 11 | JSONSchemaValidator, 12 | JTDValidator 13 | } 14 | -------------------------------------------------------------------------------- /lib/validators/json-schema-validator.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Ajv = require('ajv') 4 | const addFormats = require('ajv-formats') 5 | const addErrors = require('ajv-errors') 6 | const { isInputObjectType, isListType } = require('graphql') 7 | const Validator = require('./validator') 8 | const { MER_VALIDATION_ERR_FIELD_TYPE_UNDEFINED } = require('../errors') 9 | const { kAjv, kOpts, kOverrideFieldResolver, kValidationSchema, kBuildArgumentsSchema, kBuildInputTypeFieldSchema } = require('../symbols') 10 | const { getTypeInfo, inferJSONSchemaType } = require('../utils') 11 | 12 | class JSONSchemaValidator extends Validator { 13 | [kValidationSchema] (type, namedType, isNonNull, typeValidation, id) { 14 | let builtValidationSchema = { 15 | ...inferJSONSchemaType(namedType, isNonNull, this[kOpts].customTypeInferenceFn), 16 | $id: id 17 | } 18 | 19 | // If we have an input type, use references 20 | if (isInputObjectType(namedType)) { 21 | if (isListType(type)) { 22 | const items = { ...builtValidationSchema.items, $ref: `https://mercurius.dev/validation/${namedType.name}` } 23 | builtValidationSchema = { ...builtValidationSchema, type: 'array', items, nullable: !isNonNull } 24 | } else { 25 | builtValidationSchema = { 26 | ...builtValidationSchema, 27 | type: 'object', 28 | $ref: `https://mercurius.dev/validation/${namedType.name}`, 29 | nullable: !isNonNull 30 | } 31 | } 32 | // If we have an array of scalars, set the array type and infer the items 33 | } else if (isListType(type)) { 34 | let items = { ...inferJSONSchemaType(namedType, isNonNull, this[kOpts].customTypeInferenceFn), ...builtValidationSchema.items } 35 | if (typeValidation !== null) { 36 | items = { ...items, ...typeValidation.items } 37 | } 38 | builtValidationSchema = { ...builtValidationSchema, type: 'array', items, nullable: !isNonNull } 39 | } 40 | 41 | // Merge with existing validation 42 | if (typeValidation !== null) { 43 | builtValidationSchema = { ...typeValidation, ...builtValidationSchema } 44 | } 45 | 46 | return builtValidationSchema 47 | } 48 | 49 | [kBuildArgumentsSchema] (typeName, fieldName, schemaTypeField, fieldValidation) { 50 | // Set up field arguments validation schema 51 | const fieldArgumentsValidationSchema = { 52 | $id: `https://mercurius.dev/validation/${typeName}/${fieldName}`, 53 | type: 'object', 54 | properties: {} 55 | } 56 | 57 | for (const argument of schemaTypeField.args) { 58 | const [argumentType, namedArgumentType, isNonNull] = getTypeInfo(argument) 59 | const argumentValidation = fieldValidation !== null ? fieldValidation[argument.name] || null : null 60 | const id = `https://mercurius.dev/validation/${typeName}/${fieldName}/${argument.name}` 61 | 62 | const argumentValidationSchema = this[kValidationSchema](argumentType, namedArgumentType, isNonNull, argumentValidation, id) 63 | 64 | fieldArgumentsValidationSchema.properties[argument.name] = argumentValidationSchema 65 | } 66 | this[kOverrideFieldResolver](typeName, schemaTypeField) 67 | 68 | return fieldArgumentsValidationSchema 69 | } 70 | 71 | [kBuildInputTypeFieldSchema] (typeName, fieldName, schemaTypeField, fieldValidation) { 72 | const [fieldType, namedFieldType, isNonNull] = getTypeInfo(schemaTypeField) 73 | const id = `https://mercurius.dev/validation/${typeName}/${fieldName}` 74 | 75 | const builtFieldValidationSchema = this[kValidationSchema](fieldType, namedFieldType, isNonNull, fieldValidation, id) 76 | 77 | // Only consider fields where we have inferred the type to avoid any AJV errors 78 | if (fieldValidation !== null) { 79 | if (typeof builtFieldValidationSchema.type === 'undefined') { 80 | throw new MER_VALIDATION_ERR_FIELD_TYPE_UNDEFINED(builtFieldValidationSchema.$id) 81 | } 82 | } 83 | 84 | if (typeof builtFieldValidationSchema.type === 'string' || Array.isArray(builtFieldValidationSchema.type)) { 85 | return builtFieldValidationSchema 86 | } 87 | return null 88 | } 89 | 90 | registerValidationSchema (schema, validationSchema) { 91 | // Instantiated here to make sure it is reset after a gateway schema refresh 92 | this[kAjv] = new Ajv({ 93 | verbose: true, 94 | allErrors: true, 95 | coerceTypes: true, 96 | allowUnionTypes: true, 97 | ...this[kOpts] 98 | }) 99 | addFormats(this[kAjv]) 100 | addErrors(this[kAjv], { 101 | keepErrors: false, 102 | singleError: true 103 | }) 104 | 105 | // Traverse schema types and override resolvers with validation protection where necessary 106 | const schemasToRegister = [] 107 | 108 | // Process each type within the schema 109 | for (const [typeName, schemaType] of Object.entries(schema.getTypeMap())) { 110 | const typeValidation = validationSchema[typeName] || null 111 | let typeValidationSchema = { 112 | $id: `https://mercurius.dev/validation/${typeName}`, 113 | type: 'object', 114 | nullable: true, 115 | properties: {} 116 | } 117 | 118 | // Process each field for the type 119 | if (!typeName.startsWith('__') && typeof schemaType.getFields === 'function') { 120 | // Handle any input object type validation 121 | if (isInputObjectType(schemaType) && typeValidation !== null && typeof typeValidation.__typeValidation !== 'undefined') { 122 | typeValidationSchema = { ...typeValidation.__typeValidation, ...typeValidationSchema } 123 | } 124 | 125 | for (const [fieldName, schemaTypeField] of Object.entries(schemaType.getFields())) { 126 | const fieldValidation = typeValidation !== null ? typeValidation[fieldName] || null : null 127 | 128 | // If the field has arguments, register argument validation 129 | if (typeof schemaTypeField.args !== 'undefined' && Object.keys(schemaTypeField.args).length > 0) { 130 | schemasToRegister.push(this[kBuildArgumentsSchema](typeName, fieldName, schemaTypeField, fieldValidation)) 131 | // If the field parent type is an input type, register input object type field validation 132 | } else if (isInputObjectType(schemaType)) { 133 | const fieldValidationSchema = this[kBuildInputTypeFieldSchema](typeName, fieldName, schemaTypeField, fieldValidation) 134 | if (fieldValidationSchema !== null) { 135 | typeValidationSchema.properties[fieldName] = fieldValidationSchema 136 | } 137 | } 138 | } 139 | 140 | if (isInputObjectType(schemaType)) { 141 | schemasToRegister.push(typeValidationSchema) 142 | } 143 | } 144 | } 145 | 146 | // Load the schemas into the AJV instance 147 | for (const schemaToRegister of schemasToRegister) { 148 | this[kAjv].addSchema(schemaToRegister, schemaToRegister.$id) 149 | } 150 | 151 | // Force first compilation of each schema definition to improve performance. 152 | // This must be done in a separate step to guarantee references have been added. 153 | for (const schemaToRegister of schemasToRegister) { 154 | this[kAjv].getSchema(schemaToRegister.$id) 155 | } 156 | } 157 | } 158 | 159 | module.exports = JSONSchemaValidator 160 | -------------------------------------------------------------------------------- /lib/validators/jtd-validator.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const Ajv = require('ajv/dist/jtd') 4 | const { isInputObjectType, isListType } = require('graphql') 5 | const Validator = require('./validator') 6 | const { kAjv, kOpts, kOverrideFieldResolver, kBuildValidationSchema, kBuildArgumentsSchema, kBuildInputTypeFieldSchema } = require('../symbols') 7 | const { getTypeInfo } = require('../utils') 8 | 9 | class JTDValidator extends Validator { 10 | [kBuildValidationSchema] (type, namedType, typeValidation, id) { 11 | let builtValidationSchema = {} 12 | if (id !== null) { 13 | builtValidationSchema = { $id: id } 14 | } 15 | 16 | if (isInputObjectType(namedType)) { 17 | if (isListType(type)) { 18 | const elements = { ...builtValidationSchema.elements, ref: namedType.name } 19 | builtValidationSchema = { ...builtValidationSchema, elements } 20 | } else { 21 | builtValidationSchema = { ...builtValidationSchema, ref: namedType.name } 22 | } 23 | } 24 | 25 | if (typeValidation !== null) { 26 | builtValidationSchema = { ...builtValidationSchema, ...typeValidation } 27 | } 28 | 29 | return builtValidationSchema 30 | } 31 | 32 | [kBuildArgumentsSchema] (typeName, fieldName, schemaTypeField, fieldValidation) { 33 | // Set up field arguments validation schema 34 | const fieldInputValidationSchema = { 35 | $id: `https://mercurius.dev/validation/${typeName}/${fieldName}`, 36 | optionalProperties: {} 37 | } 38 | 39 | for (const argument of schemaTypeField.args) { 40 | const argumentValidation = fieldValidation !== null ? fieldValidation[argument.name] || null : null 41 | const [argumentType, namedArgumentType] = getTypeInfo(argument) 42 | 43 | const argumentValidationSchema = this[kBuildValidationSchema](argumentType, namedArgumentType, argumentValidation, null) 44 | 45 | fieldInputValidationSchema.optionalProperties[argument.name] = argumentValidationSchema 46 | } 47 | this[kOverrideFieldResolver](typeName, schemaTypeField) 48 | 49 | return fieldInputValidationSchema 50 | } 51 | 52 | [kBuildInputTypeFieldSchema] (typeName, fieldName, schemaTypeField, fieldValidation) { 53 | const id = `https://mercurius.dev/validation/${typeName}/${fieldName}` 54 | const [fieldType, namedFieldType] = getTypeInfo(schemaTypeField) 55 | const { $id, ...validationSchema } = this[kBuildValidationSchema](fieldType, namedFieldType, fieldValidation, id) 56 | 57 | if (Object.keys(validationSchema).length === 0) { 58 | return null 59 | } 60 | return validationSchema 61 | } 62 | 63 | registerValidationSchema (schema, validationSchema) { 64 | // Instantiated here to make sure it is reset after a gateway schema refresh 65 | this[kAjv] = new Ajv({ 66 | verbose: true, 67 | allErrors: true, 68 | // AJV does not yet support type coercion for JTD schemas: https://github.com/ajv-validator/ajv/issues/1724 69 | // coerceTypes: true, 70 | ...this[kOpts] 71 | }) 72 | 73 | // Traverse schema types and override resolvers with validation protection where necessary 74 | const definitions = {} 75 | const schemasToRegister = [] 76 | 77 | // Process each type within the schema 78 | for (const [typeName, schemaType] of Object.entries(schema.getTypeMap())) { 79 | const typeValidation = validationSchema[typeName] || null 80 | const typeValidationSchema = { 81 | $id: `https://mercurius.dev/validation/${typeName}`, 82 | optionalProperties: {}, 83 | additionalProperties: true 84 | } 85 | 86 | // Process each field for the type 87 | if (!typeName.startsWith('__') && typeof schemaType.getFields === 'function') { 88 | // Handle any input object type validation 89 | if (isInputObjectType(schemaType) && typeValidation !== null && typeof typeValidation.__typeValidation !== 'undefined') { 90 | schemasToRegister.push(typeValidation.__typeValidation) 91 | definitions[typeName] = typeValidation.__typeValidation 92 | // Otherwise handle fields as normal 93 | } else { 94 | for (const [fieldName, schemaTypeField] of Object.entries(schemaType.getFields())) { 95 | const fieldValidation = typeValidation !== null ? typeValidation[fieldName] || null : null 96 | 97 | // If the field has arguments, register argument validation 98 | if (typeof schemaTypeField.args !== 'undefined' && Object.keys(schemaTypeField.args).length > 0) { 99 | const argumentsSchema = this[kBuildArgumentsSchema](typeName, fieldName, schemaTypeField, fieldValidation) 100 | schemasToRegister.push(argumentsSchema) 101 | // If the field parent type is an input type, register input object type field validation 102 | } else if (isInputObjectType(schemaType)) { 103 | const fieldValidationSchema = this[kBuildInputTypeFieldSchema](typeName, fieldName, schemaTypeField, fieldValidation) 104 | if (fieldValidationSchema !== null) { 105 | typeValidationSchema.optionalProperties[fieldName] = fieldValidationSchema 106 | } 107 | } 108 | } 109 | 110 | if (isInputObjectType(schemaType)) { 111 | schemasToRegister.push(typeValidationSchema) 112 | const { $id, ...typeValidationSchemaWithoutId } = typeValidationSchema 113 | definitions[typeName] = typeValidationSchemaWithoutId 114 | } 115 | } 116 | } 117 | } 118 | 119 | for (const { $id, ...validationSchema } of schemasToRegister) { 120 | this[kAjv].addSchema({ ...validationSchema, definitions: { ...validationSchema.definitions, ...definitions } }, $id) 121 | // Force first compilation to improve performance 122 | this[kAjv].getSchema($id) 123 | } 124 | } 125 | } 126 | 127 | module.exports = JTDValidator 128 | -------------------------------------------------------------------------------- /lib/validators/validator.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { MER_VALIDATION_ERR_FAILED_VALIDATION } = require('../errors') 4 | const { kOpts, kAjv, kMakeResolver, kOverrideFieldResolver } = require('../symbols') 5 | 6 | class Validator { 7 | constructor (opts) { 8 | this[kOpts] = opts 9 | } 10 | 11 | [kOverrideFieldResolver] (typeName, schemaTypeField) { 12 | // Overwrite field resolver 13 | const fieldName = schemaTypeField.name 14 | if (typeof schemaTypeField.resolve === 'function') { 15 | const originalFieldResolver = schemaTypeField.resolve 16 | schemaTypeField.resolve = this[kMakeResolver](typeName, fieldName, originalFieldResolver) 17 | } else { 18 | schemaTypeField.resolve = this[kMakeResolver](typeName, fieldName, (parent) => parent[fieldName]) 19 | } 20 | } 21 | 22 | [kMakeResolver] (type, field, resolverFn) { 23 | return async (parent, args, context, info) => { 24 | const errors = [] 25 | const validate = this[kAjv].getSchema(`https://mercurius.dev/validation/${type}/${field}`) 26 | const valid = validate(args) 27 | if (!valid) { 28 | errors.push(...validate.errors) 29 | } 30 | 31 | if (errors.length > 0) { 32 | throw new MER_VALIDATION_ERR_FAILED_VALIDATION(`Failed Validation on arguments for field '${type}.${field}'`, errors) 33 | } 34 | 35 | return resolverFn(parent, args, context, info) 36 | } 37 | } 38 | } 39 | 40 | module.exports = Validator 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mercurius-validation", 3 | "version": "6.0.1", 4 | "description": "Mercurius Validation Plugin adds configurable Validation support to Mercurius.", 5 | "main": "index.js", 6 | "types": "index.d.ts", 7 | "scripts": { 8 | "unit": "tap --100 test/*.js", 9 | "cov": "tap --coverage-report=html -J test/*.js", 10 | "lint": "npm run lint:standard", 11 | "lint:standard": "standard | snazzy", 12 | "lint:typescript": "standard --parser @typescript-eslint/parser --plugin @typescript-eslint/eslint-plugin index.d.ts test/types/*.ts | snazzy", 13 | "typescript": "tsd", 14 | "test": "npm run lint && npm run unit && npm run typescript" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/mercurius-js/validation.git" 19 | }, 20 | "author": "Jonny Green ", 21 | "contributors": [ 22 | { 23 | "name": "Matteo Collina", 24 | "email": "hello@matteocollina.com" 25 | }, 26 | { 27 | "name": "Simone Sanfratello", 28 | "email": "simone@braceslab.com" 29 | } 30 | ], 31 | "license": "MIT", 32 | "bugs": { 33 | "url": "https://github.com/mercurius-js/validation/issues" 34 | }, 35 | "homepage": "https://github.com/mercurius-js/validation", 36 | "devDependencies": { 37 | "@mercuriusjs/federation": "^5.0.0", 38 | "@mercuriusjs/gateway": "^5.0.0", 39 | "@sinonjs/fake-timers": "^11.2.2", 40 | "@types/node": "^22.0.0", 41 | "@types/ws": "^8.5.10", 42 | "@typescript-eslint/eslint-plugin": "^5.30.3", 43 | "@typescript-eslint/parser": "^5.30.3", 44 | "autocannon": "^8.0.0", 45 | "concurrently": "^9.0.0", 46 | "fastify": "^5.0.0", 47 | "mercurius": "^16.0.0", 48 | "pre-commit": "^1.2.2", 49 | "snazzy": "^9.0.0", 50 | "standard": "^17.1.0", 51 | "tap": "^16.3.0", 52 | "tsd": "^0.32.0", 53 | "typescript": "^5.4.2", 54 | "wait-on": "^8.0.0" 55 | }, 56 | "dependencies": { 57 | "@fastify/error": "^4.0.0", 58 | "ajv": "^8.6.2", 59 | "ajv-errors": "^3.0.0", 60 | "ajv-formats": "^3.0.1", 61 | "fastify-plugin": "^5.0.1", 62 | "graphql": "^16.2.0" 63 | }, 64 | "tsd": { 65 | "directory": "./test/types" 66 | }, 67 | "directories": { 68 | "lib": "lib", 69 | "test": "test" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /test/advanced-validation.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const t = require('tap') 4 | const Fastify = require('fastify') 5 | const mercurius = require('mercurius') 6 | const mercuriusValidation = require('..') 7 | 8 | const schema = ` 9 | ${mercuriusValidation.graphQLTypeDefs} 10 | 11 | type Message { 12 | id: ID! 13 | text: String 14 | } 15 | 16 | input Filters { 17 | id: ID 18 | text: String 19 | } 20 | 21 | input NestedFilters { 22 | input: Filters 23 | } 24 | 25 | input ArrayFilters { 26 | values: [String] 27 | filters: [Filters] 28 | } 29 | 30 | type Query { 31 | message(id: ID @constraint(type: "string" format: "uuid")): Message 32 | messages( 33 | filters: Filters 34 | nestedFilters: NestedFilters 35 | arrayScalarFilters: [String] @constraint(minItems: 2) 36 | arrayObjectFilters: [ArrayFilters] @constraint(minItems: 1) 37 | ): [Message] 38 | } 39 | ` 40 | 41 | const messages = [ 42 | { 43 | id: 0, 44 | text: 'Some system message.' 45 | }, 46 | { 47 | id: 1, 48 | text: 'Hello there' 49 | }, 50 | { 51 | id: 2, 52 | text: 'Give me a place to stand, a lever long enough and a fulcrum. And I can move the Earth.' 53 | }, 54 | { 55 | id: 3, 56 | text: '' 57 | } 58 | ] 59 | 60 | const resolvers = { 61 | Query: { 62 | message: async (_, { id }) => { 63 | return messages.find(message => message.id === Number(id)) 64 | }, 65 | messages: async () => { 66 | return messages 67 | } 68 | } 69 | } 70 | 71 | t.test('Advanced', t => { 72 | t.plan(2) 73 | 74 | t.test('when mode is JSON Schema', t => { 75 | t.plan(2) 76 | 77 | t.test('should all work independently together', async (t) => { 78 | t.plan(1) 79 | 80 | const app = Fastify() 81 | t.teardown(app.close.bind(app)) 82 | 83 | app.register(mercurius, { 84 | schema, 85 | resolvers 86 | }) 87 | app.register(mercuriusValidation, { 88 | schema: { 89 | Filters: { 90 | text: { minLength: 1 } 91 | }, 92 | ArrayFilters: { 93 | values: { type: 'array', minItems: 2 } 94 | }, 95 | Query: { 96 | message: { 97 | id: async () => { 98 | const error = new Error('kaboom') 99 | error.details = ['kaboom'] 100 | throw error 101 | } 102 | } 103 | } 104 | } 105 | }) 106 | 107 | const query = `query { 108 | message(id: "1") { 109 | id 110 | text 111 | } 112 | messages( 113 | filters: { text: "" } 114 | arrayObjectFilters: [ 115 | { values: [] }, { values: ["hello", "there"] } 116 | ] 117 | ) { 118 | id 119 | text 120 | } 121 | directiveMessages: messages( 122 | arrayScalarFilters: [""] 123 | arrayObjectFilters: [] 124 | ) { 125 | id 126 | text 127 | } 128 | }` 129 | 130 | const response = await app.inject({ 131 | method: 'POST', 132 | headers: { 'content-type': 'application/json' }, 133 | url: '/graphql', 134 | body: JSON.stringify({ query }) 135 | }) 136 | 137 | t.same(JSON.parse(response.body), { 138 | data: { 139 | message: null, 140 | messages: null, 141 | directiveMessages: null 142 | }, 143 | errors: [ 144 | { 145 | message: "Failed Validation on arguments for field 'Query.messages'", 146 | locations: [ 147 | { 148 | line: 6, 149 | column: 9 150 | } 151 | ], 152 | path: [ 153 | 'messages' 154 | ], 155 | extensions: { 156 | code: 'MER_VALIDATION_ERR_FAILED_VALIDATION', 157 | name: 'ValidationError', 158 | details: [ 159 | { 160 | instancePath: '/filters/text', 161 | schemaPath: 'https://mercurius.dev/validation/Filters/properties/text/minLength', 162 | keyword: 'minLength', 163 | params: { 164 | limit: 1 165 | }, 166 | message: 'must NOT have fewer than 1 characters', 167 | schema: 1, 168 | parentSchema: { 169 | minLength: 1, 170 | type: ['string', 'null'], 171 | $id: 'https://mercurius.dev/validation/Filters/text' 172 | }, 173 | data: '' 174 | }, 175 | { 176 | instancePath: '/arrayObjectFilters/0/values', 177 | schemaPath: '#/properties/values/minItems', 178 | keyword: 'minItems', 179 | params: { 180 | limit: 2 181 | }, 182 | message: 'must NOT have fewer than 2 items', 183 | schema: 2, 184 | parentSchema: { 185 | minItems: 2, 186 | type: 'array', 187 | $id: 'https://mercurius.dev/validation/ArrayFilters/values', 188 | items: { 189 | type: ['string', 'null'] 190 | }, 191 | nullable: true 192 | }, 193 | data: [] 194 | } 195 | ] 196 | } 197 | }, 198 | { 199 | message: "Failed Validation on arguments for field 'Query.messages'", 200 | locations: [ 201 | { 202 | line: 15, 203 | column: 9 204 | } 205 | ], 206 | path: [ 207 | 'directiveMessages' 208 | ], 209 | extensions: { 210 | code: 'MER_VALIDATION_ERR_FAILED_VALIDATION', 211 | name: 'ValidationError', 212 | details: [ 213 | { 214 | instancePath: '/arrayScalarFilters', 215 | schemaPath: '#/properties/arrayScalarFilters/minItems', 216 | keyword: 'minItems', 217 | params: { 218 | limit: 2 219 | }, 220 | message: 'must NOT have fewer than 2 items', 221 | schema: 2, 222 | parentSchema: { 223 | minItems: 2, 224 | type: 'array', 225 | $id: 'https://mercurius.dev/validation/Query/messages/arrayScalarFilters', 226 | items: { 227 | type: ['string', 'null'] 228 | }, 229 | nullable: true 230 | }, 231 | data: [ 232 | '' 233 | ] 234 | }, 235 | { 236 | instancePath: '/arrayObjectFilters', 237 | schemaPath: '#/properties/arrayObjectFilters/minItems', 238 | keyword: 'minItems', 239 | params: { 240 | limit: 1 241 | }, 242 | message: 'must NOT have fewer than 1 items', 243 | schema: 1, 244 | parentSchema: { 245 | minItems: 1, 246 | $id: 'https://mercurius.dev/validation/Query/messages/arrayObjectFilters', 247 | type: 'array', 248 | items: { 249 | $ref: 'https://mercurius.dev/validation/ArrayFilters' 250 | }, 251 | nullable: true 252 | }, 253 | data: [] 254 | } 255 | ] 256 | } 257 | }, 258 | { 259 | message: "Failed Validation on arguments for field 'Query.message'", 260 | locations: [ 261 | { 262 | line: 2, 263 | column: 9 264 | } 265 | ], 266 | path: [ 267 | 'message' 268 | ], 269 | extensions: { 270 | code: 'MER_VALIDATION_ERR_FAILED_VALIDATION', 271 | name: 'ValidationError', 272 | details: [ 273 | { 274 | details: [ 275 | 'kaboom' 276 | ] 277 | } 278 | ] 279 | } 280 | } 281 | ] 282 | }) 283 | }) 284 | 285 | t.test('directive validation should run after in-band validation', async t => { 286 | t.plan(2) 287 | 288 | const app = Fastify() 289 | t.teardown(app.close.bind(app)) 290 | 291 | app.register(mercurius, { 292 | schema, 293 | resolvers 294 | }) 295 | app.register(mercuriusValidation, { 296 | schema: { 297 | Query: { 298 | message: { 299 | id: { type: 'string', minLength: 1 } 300 | } 301 | } 302 | } 303 | }) 304 | 305 | { 306 | const query = `query { 307 | message(id: "") { 308 | id 309 | text 310 | } 311 | }` 312 | 313 | const response = await app.inject({ 314 | method: 'POST', 315 | headers: { 'content-type': 'application/json' }, 316 | url: '/graphql', 317 | body: JSON.stringify({ query }) 318 | }) 319 | 320 | t.same(JSON.parse(response.body), { 321 | data: { 322 | message: null 323 | }, 324 | errors: [ 325 | { 326 | message: "Failed Validation on arguments for field 'Query.message'", 327 | locations: [ 328 | { 329 | line: 2, 330 | column: 11 331 | } 332 | ], 333 | path: [ 334 | 'message' 335 | ], 336 | extensions: { 337 | code: 'MER_VALIDATION_ERR_FAILED_VALIDATION', 338 | name: 'ValidationError', 339 | details: [ 340 | { 341 | instancePath: '/id', 342 | schemaPath: '#/properties/id/minLength', 343 | keyword: 'minLength', 344 | params: { 345 | limit: 1 346 | }, 347 | message: 'must NOT have fewer than 1 characters', 348 | schema: 1, 349 | parentSchema: { 350 | type: 'string', 351 | minLength: 1, 352 | $id: 'https://mercurius.dev/validation/Query/message/id' 353 | }, 354 | data: '' 355 | } 356 | ] 357 | } 358 | } 359 | ] 360 | }) 361 | } 362 | 363 | { 364 | const query = `query { 365 | message(id: "not-uuid") { 366 | id 367 | text 368 | } 369 | }` 370 | 371 | const response = await app.inject({ 372 | method: 'POST', 373 | headers: { 'content-type': 'application/json' }, 374 | url: '/graphql', 375 | body: JSON.stringify({ query }) 376 | }) 377 | 378 | t.same(JSON.parse(response.body), { 379 | data: { 380 | message: null 381 | }, 382 | errors: [ 383 | { 384 | message: "Failed Validation on arguments for field 'Query.message'", 385 | locations: [ 386 | { 387 | line: 2, 388 | column: 11 389 | } 390 | ], 391 | path: [ 392 | 'message' 393 | ], 394 | extensions: { 395 | code: 'MER_VALIDATION_ERR_FAILED_VALIDATION', 396 | name: 'ValidationError', 397 | details: [ 398 | { 399 | instancePath: '/id', 400 | schemaPath: '#/properties/id/format', 401 | keyword: 'format', 402 | params: { 403 | format: 'uuid' 404 | }, 405 | message: 'must match format "uuid"', 406 | schema: 'uuid', 407 | parentSchema: { 408 | type: 'string', 409 | format: 'uuid', 410 | $id: 'https://mercurius.dev/validation/Query/message/id' 411 | }, 412 | data: 'not-uuid' 413 | } 414 | ] 415 | } 416 | } 417 | ] 418 | }) 419 | } 420 | }) 421 | }) 422 | 423 | t.test('when mode is JTD', t => { 424 | t.plan(2) 425 | 426 | t.test('should all work independently together', async (t) => { 427 | t.plan(1) 428 | 429 | const app = Fastify() 430 | t.teardown(app.close.bind(app)) 431 | 432 | app.register(mercurius, { 433 | schema, 434 | resolvers 435 | }) 436 | app.register(mercuriusValidation, { 437 | mode: 'JTD', 438 | schema: { 439 | Filters: { 440 | text: { enum: ['hello', 'there'] } 441 | }, 442 | Query: { 443 | message: { 444 | id: async () => { 445 | const error = new Error('kaboom') 446 | error.details = ['kaboom'] 447 | throw error 448 | } 449 | } 450 | } 451 | } 452 | }) 453 | 454 | const query = `query { 455 | message(id: "1") { 456 | id 457 | text 458 | } 459 | messages( 460 | filters: { text: "" } 461 | ) { 462 | id 463 | text 464 | } 465 | directiveMessages: messages( 466 | arrayScalarFilters: [""] 467 | arrayObjectFilters: [] 468 | ) { 469 | id 470 | text 471 | } 472 | }` 473 | 474 | const response = await app.inject({ 475 | method: 'POST', 476 | headers: { 'content-type': 'application/json' }, 477 | url: '/graphql', 478 | body: JSON.stringify({ query }) 479 | }) 480 | 481 | t.same(JSON.parse(response.body), { 482 | data: { 483 | message: null, 484 | messages: null, 485 | directiveMessages: null 486 | }, 487 | errors: [ 488 | { 489 | message: "Failed Validation on arguments for field 'Query.messages'", 490 | locations: [ 491 | { 492 | line: 6, 493 | column: 9 494 | } 495 | ], 496 | path: [ 497 | 'messages' 498 | ], 499 | extensions: { 500 | code: 'MER_VALIDATION_ERR_FAILED_VALIDATION', 501 | name: 'ValidationError', 502 | details: [ 503 | { 504 | instancePath: '/filters/text', 505 | schemaPath: '/definitions/Filters/optionalProperties/text/enum', 506 | keyword: 'enum', 507 | params: { 508 | allowedValues: [ 509 | 'hello', 510 | 'there' 511 | ] 512 | }, 513 | message: 'must be equal to one of the allowed values', 514 | schema: [ 515 | 'hello', 516 | 'there' 517 | ], 518 | parentSchema: { 519 | enum: [ 520 | 'hello', 521 | 'there' 522 | ] 523 | }, 524 | data: '' 525 | } 526 | ] 527 | } 528 | }, 529 | { 530 | message: "Failed Validation on arguments for field 'Query.messages'", 531 | locations: [ 532 | { 533 | line: 12, 534 | column: 9 535 | } 536 | ], 537 | path: [ 538 | 'directiveMessages' 539 | ], 540 | extensions: { 541 | code: 'MER_VALIDATION_ERR_FAILED_VALIDATION', 542 | name: 'ValidationError', 543 | details: [ 544 | { 545 | instancePath: '/arrayScalarFilters', 546 | schemaPath: '#/properties/arrayScalarFilters/minItems', 547 | keyword: 'minItems', 548 | params: { 549 | limit: 2 550 | }, 551 | message: 'must NOT have fewer than 2 items', 552 | schema: 2, 553 | parentSchema: { 554 | minItems: 2, 555 | type: 'array', 556 | $id: 'https://mercurius.dev/validation/Query/messages/arrayScalarFilters', 557 | items: { 558 | type: ['string', 'null'] 559 | }, 560 | nullable: true 561 | }, 562 | data: [ 563 | '' 564 | ] 565 | }, 566 | { 567 | instancePath: '/arrayObjectFilters', 568 | schemaPath: '#/properties/arrayObjectFilters/minItems', 569 | keyword: 'minItems', 570 | params: { 571 | limit: 1 572 | }, 573 | message: 'must NOT have fewer than 1 items', 574 | schema: 1, 575 | parentSchema: { 576 | minItems: 1, 577 | $id: 'https://mercurius.dev/validation/Query/messages/arrayObjectFilters', 578 | type: 'array', 579 | items: { 580 | $ref: 'https://mercurius.dev/validation/ArrayFilters' 581 | }, 582 | nullable: true 583 | }, 584 | data: [] 585 | } 586 | ] 587 | } 588 | }, 589 | { 590 | message: "Failed Validation on arguments for field 'Query.message'", 591 | locations: [ 592 | { 593 | line: 2, 594 | column: 9 595 | } 596 | ], 597 | path: [ 598 | 'message' 599 | ], 600 | extensions: { 601 | code: 'MER_VALIDATION_ERR_FAILED_VALIDATION', 602 | name: 'ValidationError', 603 | details: [ 604 | { 605 | details: [ 606 | 'kaboom' 607 | ] 608 | } 609 | ] 610 | } 611 | } 612 | ] 613 | }) 614 | }) 615 | 616 | t.test('directive validation should run after in-band validation', async t => { 617 | t.plan(2) 618 | 619 | const app = Fastify() 620 | t.teardown(app.close.bind(app)) 621 | 622 | app.register(mercurius, { 623 | schema, 624 | resolvers 625 | }) 626 | app.register(mercuriusValidation, { 627 | mode: 'JTD', 628 | schema: { 629 | Query: { 630 | message: { 631 | id: { enum: ['hello', 'there'] } 632 | } 633 | } 634 | } 635 | }) 636 | 637 | { 638 | const query = `query { 639 | message(id: "wrong") { 640 | id 641 | text 642 | } 643 | }` 644 | 645 | const response = await app.inject({ 646 | method: 'POST', 647 | headers: { 'content-type': 'application/json' }, 648 | url: '/graphql', 649 | body: JSON.stringify({ query }) 650 | }) 651 | 652 | t.same(JSON.parse(response.body), { 653 | data: { 654 | message: null 655 | }, 656 | errors: [ 657 | { 658 | message: "Failed Validation on arguments for field 'Query.message'", 659 | locations: [ 660 | { 661 | line: 2, 662 | column: 11 663 | } 664 | ], 665 | path: [ 666 | 'message' 667 | ], 668 | extensions: { 669 | code: 'MER_VALIDATION_ERR_FAILED_VALIDATION', 670 | name: 'ValidationError', 671 | details: [ 672 | { 673 | instancePath: '/id', 674 | schemaPath: '/optionalProperties/id/enum', 675 | keyword: 'enum', 676 | params: { 677 | allowedValues: [ 678 | 'hello', 679 | 'there' 680 | ] 681 | }, 682 | message: 'must be equal to one of the allowed values', 683 | schema: [ 684 | 'hello', 685 | 'there' 686 | ], 687 | parentSchema: { 688 | enum: [ 689 | 'hello', 690 | 'there' 691 | ] 692 | }, 693 | data: 'wrong' 694 | } 695 | ] 696 | } 697 | } 698 | ] 699 | }) 700 | } 701 | 702 | { 703 | const query = `query { 704 | message(id: "hello") { 705 | id 706 | text 707 | } 708 | }` 709 | 710 | const response = await app.inject({ 711 | method: 'POST', 712 | headers: { 'content-type': 'application/json' }, 713 | url: '/graphql', 714 | body: JSON.stringify({ query }) 715 | }) 716 | 717 | t.same(JSON.parse(response.body), { 718 | data: { 719 | message: null 720 | }, 721 | errors: [ 722 | { 723 | message: "Failed Validation on arguments for field 'Query.message'", 724 | locations: [ 725 | { 726 | line: 2, 727 | column: 11 728 | } 729 | ], 730 | path: [ 731 | 'message' 732 | ], 733 | extensions: { 734 | code: 'MER_VALIDATION_ERR_FAILED_VALIDATION', 735 | name: 'ValidationError', 736 | details: [ 737 | { 738 | instancePath: '/id', 739 | schemaPath: '#/properties/id/format', 740 | keyword: 'format', 741 | params: { 742 | format: 'uuid' 743 | }, 744 | message: 'must match format "uuid"', 745 | schema: 'uuid', 746 | parentSchema: { 747 | type: 'string', 748 | format: 'uuid', 749 | $id: 'https://mercurius.dev/validation/Query/message/id' 750 | }, 751 | data: 'hello' 752 | } 753 | ] 754 | } 755 | } 756 | ] 757 | }) 758 | } 759 | }) 760 | }) 761 | }) 762 | -------------------------------------------------------------------------------- /test/directive-definition.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const t = require('tap') 4 | const { parse, GraphQLSchema, extendSchema, buildSchema } = require('graphql') 5 | const { graphQLTypeDefs } = require('..') 6 | 7 | t.test('directive', t => { 8 | t.plan(1) 9 | 10 | t.test('validationTypeDefs', t => { 11 | t.plan(2) 12 | 13 | t.test('should be a valid GraphQL type definition', t => { 14 | t.plan(1) 15 | 16 | parse(graphQLTypeDefs) 17 | t.ok('Valid GraphQL type definition') 18 | }) 19 | 20 | t.test('should be able to extend an existing executable schema', t => { 21 | t.plan(1) 22 | 23 | const graphQLSchemaToExtend = buildSchema(` 24 | type Query { 25 | message(id: ID @constraint(wrong: String)): String 26 | } 27 | `, { assumeValid: true }) 28 | t.type(extendSchema(graphQLSchemaToExtend, parse(graphQLTypeDefs)), GraphQLSchema) 29 | }) 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /test/errors.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const t = require('tap') 4 | const errors = require('../lib/errors') 5 | const Fastify = require('fastify') 6 | const mercurius = require('mercurius') 7 | const mercuriusValidation = require('..') 8 | 9 | const schema = ` 10 | ${mercuriusValidation.graphQLTypeDefs} 11 | 12 | type Message { 13 | id: ID! 14 | text: String 15 | } 16 | 17 | type Query { 18 | # the result type must be non-nullable for the statusCode from the 19 | # validation error to be propogated. If the result type is nullable 20 | # then the graphql library does error protection which results in a 21 | # status code of 200 ignoring the statusCode on the error: 22 | messageNotNullable(id: ID @constraint(type: "string" format: "uuid")): Message! 23 | 24 | messageNullable(id: ID @constraint(type: "string" format: "uuid")): Message 25 | } 26 | ` 27 | 28 | const resolvers = { 29 | Query: { 30 | messageNotNullable: async (_, { id }) => ({ 31 | id: 0, 32 | text: 'Some system message.' 33 | }), 34 | messageNullable: async (_, { id }) => null 35 | } 36 | } 37 | 38 | t.test('errors', t => { 39 | t.plan(3) 40 | 41 | t.test('MER_VALIDATION_ERR_FAILED_VALIDATION', t => { 42 | t.plan(1) 43 | 44 | t.test('toString', t => { 45 | t.plan(1) 46 | 47 | t.test('should print a validation error to string', t => { 48 | t.plan(2) 49 | 50 | const error = new errors.MER_VALIDATION_ERR_FAILED_VALIDATION('some message', []) 51 | 52 | t.same(error.toString(), 'ValidationError [MER_VALIDATION_ERR_FAILED_VALIDATION]: some message') 53 | t.equal(error.statusCode, 400) 54 | }) 55 | }) 56 | }) 57 | 58 | t.test('Validation errors result in a response status code of 400 when result is not nullable', async (t) => { 59 | t.plan(2) 60 | 61 | const app = Fastify() 62 | t.teardown(app.close.bind(app)) 63 | 64 | app.register(mercurius, { 65 | schema, 66 | resolvers 67 | }) 68 | app.register(mercuriusValidation) 69 | 70 | const query = `query { 71 | messageNotNullable(id: "") { 72 | id 73 | text 74 | } 75 | }` 76 | 77 | const response = await app.inject({ 78 | method: 'POST', 79 | headers: { 'content-type': 'application/json' }, 80 | url: '/graphql', 81 | body: JSON.stringify({ query }) 82 | }) 83 | 84 | t.same(JSON.parse(response.body), { 85 | data: null, 86 | errors: [ 87 | { 88 | message: 'Failed Validation on arguments for field \'Query.messageNotNullable\'', 89 | locations: [{ 90 | line: 2, 91 | column: 7 92 | }], 93 | path: [ 94 | 'messageNotNullable' 95 | ], 96 | extensions: { 97 | code: 'MER_VALIDATION_ERR_FAILED_VALIDATION', 98 | name: 'ValidationError', 99 | details: [ 100 | { 101 | instancePath: '/id', 102 | schemaPath: '#/properties/id/format', 103 | keyword: 'format', 104 | params: { 105 | format: 'uuid' 106 | }, 107 | message: 'must match format "uuid"', 108 | schema: 'uuid', 109 | parentSchema: { 110 | $id: 'https://mercurius.dev/validation/Query/messageNotNullable/id', 111 | type: 'string', 112 | format: 'uuid' 113 | }, 114 | data: '' 115 | } 116 | ] 117 | } 118 | } 119 | ] 120 | }) 121 | t.equal(response.statusCode, 400) 122 | }) 123 | 124 | t.test('Validation errors result in a response status code of 200 when result is nullable', async (t) => { 125 | t.plan(2) 126 | 127 | const app = Fastify() 128 | t.teardown(app.close.bind(app)) 129 | 130 | app.register(mercurius, { 131 | schema, 132 | resolvers 133 | }) 134 | app.register(mercuriusValidation) 135 | 136 | const query = `query { 137 | messageNullable(id: "") { 138 | id 139 | text 140 | } 141 | }` 142 | 143 | const response = await app.inject({ 144 | method: 'POST', 145 | headers: { 'content-type': 'application/json' }, 146 | url: '/graphql', 147 | body: JSON.stringify({ query }) 148 | }) 149 | 150 | t.same(JSON.parse(response.body), { 151 | data: { 152 | messageNullable: null 153 | }, 154 | errors: [ 155 | { 156 | message: 'Failed Validation on arguments for field \'Query.messageNullable\'', 157 | locations: [{ 158 | line: 2, 159 | column: 7 160 | }], 161 | path: [ 162 | 'messageNullable' 163 | ], 164 | extensions: { 165 | code: 'MER_VALIDATION_ERR_FAILED_VALIDATION', 166 | name: 'ValidationError', 167 | details: [ 168 | { 169 | instancePath: '/id', 170 | schemaPath: '#/properties/id/format', 171 | keyword: 'format', 172 | params: { 173 | format: 'uuid' 174 | }, 175 | message: 'must match format "uuid"', 176 | schema: 'uuid', 177 | parentSchema: { 178 | $id: 'https://mercurius.dev/validation/Query/messageNullable/id', 179 | type: 'string', 180 | format: 'uuid' 181 | }, 182 | data: '' 183 | } 184 | ] 185 | } 186 | } 187 | ] 188 | }) 189 | t.equal(response.statusCode, 200) 190 | }) 191 | }) 192 | -------------------------------------------------------------------------------- /test/function-validation.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const t = require('tap') 4 | const Fastify = require('fastify') 5 | const mercurius = require('mercurius') 6 | const mercuriusValidation = require('..') 7 | const { GraphQLSchema } = require('graphql') 8 | 9 | const schema = ` 10 | type Message { 11 | id: ID! 12 | text: String 13 | } 14 | 15 | input Filters { 16 | text: String 17 | } 18 | 19 | input NestedFilters { 20 | input: Filters 21 | } 22 | 23 | type Query { 24 | message(id: Int unused: Int): Message 25 | messages(filters: Filters, nestedFilters: NestedFilters): [Message] 26 | } 27 | ` 28 | 29 | const messages = [ 30 | { 31 | id: 0, 32 | text: 'Some system message.' 33 | }, 34 | { 35 | id: 1, 36 | text: 'Hello there' 37 | }, 38 | { 39 | id: 2, 40 | text: 'Give me a place to stand, a lever long enough and a fulcrum. And I can move the Earth.' 41 | }, 42 | { 43 | id: 3, 44 | text: '' 45 | } 46 | ] 47 | 48 | const resolvers = { 49 | Query: { 50 | message: async (_, { id }) => { 51 | return messages.find(message => message.id === Number(id)) 52 | }, 53 | messages: async () => { 54 | return messages 55 | } 56 | } 57 | } 58 | 59 | t.test('Function validators', t => { 60 | t.plan(3) 61 | 62 | t.test('should protect the schema and not affect operations when everything is okay', async (t) => { 63 | t.plan(9) 64 | 65 | const app = Fastify() 66 | t.teardown(app.close.bind(app)) 67 | 68 | app.register(mercurius, { 69 | schema, 70 | resolvers 71 | }) 72 | app.register(mercuriusValidation, { 73 | schema: { 74 | Query: { 75 | message: { 76 | id: async (metadata, argumentValue, parent, args, context, info) => { 77 | t.ok('should be called') 78 | t.same(metadata, { type: 'Query', field: 'message', argument: 'id' }) 79 | t.equal(argumentValue, 1) 80 | t.type(parent, 'object') 81 | t.type(args, 'object') 82 | t.type(context, 'object') 83 | t.type(info, 'object') 84 | t.type(info.schema, GraphQLSchema) 85 | return true 86 | } 87 | } 88 | } 89 | } 90 | }) 91 | 92 | const query = `query { 93 | message(id: 1, unused: 1) { 94 | id 95 | text 96 | } 97 | messages(filters: { text: "hello" }, nestedFilters: { input: { text: "hello" } }) { 98 | id 99 | text 100 | } 101 | }` 102 | 103 | const response = await app.inject({ 104 | method: 'POST', 105 | headers: { 'content-type': 'application/json' }, 106 | url: '/graphql', 107 | body: JSON.stringify({ query }) 108 | }) 109 | 110 | t.same(JSON.parse(response.body), { 111 | data: { 112 | message: { 113 | id: '1', 114 | text: 'Hello there' 115 | }, 116 | messages: [ 117 | { 118 | id: '0', 119 | text: 'Some system message.' 120 | }, 121 | { 122 | id: '1', 123 | text: 'Hello there' 124 | }, 125 | { 126 | id: '2', 127 | text: 'Give me a place to stand, a lever long enough and a fulcrum. And I can move the Earth.' 128 | }, 129 | { 130 | id: '3', 131 | text: '' 132 | } 133 | ] 134 | } 135 | }) 136 | }) 137 | 138 | t.test('should protect the schema arguments and error accordingly', async (t) => { 139 | t.plan(4) 140 | 141 | const app = Fastify() 142 | t.teardown(app.close.bind(app)) 143 | 144 | app.register(mercurius, { 145 | schema, 146 | resolvers 147 | }) 148 | app.register(mercuriusValidation, { 149 | schema: { 150 | Query: { 151 | message: { 152 | id: async (metadata, argumentValue) => { 153 | t.ok('should be called') 154 | t.same(metadata, { type: 'Query', field: 'message', argument: 'id' }) 155 | t.equal(argumentValue, 32768) 156 | const error = new Error('kaboom') 157 | error.data = 'kaboom data' 158 | throw error 159 | } 160 | } 161 | } 162 | } 163 | }) 164 | 165 | const query = `query { 166 | message(id: 32768) { 167 | id 168 | text 169 | } 170 | }` 171 | 172 | const response = await app.inject({ 173 | method: 'POST', 174 | headers: { 'content-type': 'application/json' }, 175 | url: '/graphql', 176 | body: JSON.stringify({ query }) 177 | }) 178 | 179 | t.same(JSON.parse(response.body), { 180 | data: { 181 | message: null 182 | }, 183 | errors: [ 184 | { 185 | message: "Failed Validation on arguments for field 'Query.message'", 186 | locations: [{ 187 | line: 2, 188 | column: 7 189 | }], 190 | path: [ 191 | 'message' 192 | ], 193 | extensions: { 194 | code: 'MER_VALIDATION_ERR_FAILED_VALIDATION', 195 | name: 'ValidationError', 196 | details: [ 197 | { 198 | data: 'kaboom data' 199 | } 200 | ] 201 | } 202 | } 203 | ] 204 | }) 205 | }) 206 | 207 | t.test('should handle when validation is mismatched with the schema and not affect existing functionality', async (t) => { 208 | t.plan(4) 209 | 210 | const app = Fastify() 211 | t.teardown(app.close.bind(app)) 212 | 213 | app.register(mercurius, { 214 | schema, 215 | resolvers 216 | }) 217 | app.register(mercuriusValidation, { 218 | schema: { 219 | Wrong: { 220 | text: { 221 | arg: async () => { 222 | t.fail('should not be called when type name is wrong') 223 | } 224 | } 225 | }, 226 | Message: { 227 | wrong: { 228 | arg: async () => { 229 | t.fail('should not be called when field name is wrong') 230 | } 231 | } 232 | }, 233 | Query: { 234 | message: { 235 | id: async (metadata, argumentValue) => { 236 | t.ok('should be called') 237 | t.same(metadata, { type: 'Query', field: 'message', argument: 'id' }) 238 | t.equal(argumentValue, 32768) 239 | const error = new Error('kaboom') 240 | error.data = 'kaboom data' 241 | throw error 242 | }, 243 | wrong: async () => { 244 | t.fail('should not be called when arg name is wrong') 245 | } 246 | } 247 | } 248 | } 249 | }) 250 | 251 | const query = `query { 252 | message(id: 32768) { 253 | id 254 | text 255 | } 256 | }` 257 | 258 | const response = await app.inject({ 259 | method: 'POST', 260 | headers: { 'content-type': 'application/json' }, 261 | url: '/graphql', 262 | body: JSON.stringify({ query }) 263 | }) 264 | 265 | t.same(JSON.parse(response.body), { 266 | data: { 267 | message: null 268 | }, 269 | errors: [ 270 | { 271 | message: "Failed Validation on arguments for field 'Query.message'", 272 | locations: [{ 273 | line: 2, 274 | column: 7 275 | }], 276 | path: [ 277 | 'message' 278 | ], 279 | extensions: { 280 | code: 'MER_VALIDATION_ERR_FAILED_VALIDATION', 281 | name: 'ValidationError', 282 | details: [ 283 | { 284 | data: 'kaboom data' 285 | } 286 | ] 287 | } 288 | } 289 | ] 290 | }) 291 | }) 292 | }) 293 | -------------------------------------------------------------------------------- /test/gateway-validation.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const t = require('tap') 4 | const Fastify = require('fastify') 5 | const { mercuriusFederationPlugin } = require('@mercuriusjs/federation') 6 | const mercuriusGateway = require('@mercuriusjs/gateway') 7 | const mercuriusValidation = require('..') 8 | 9 | async function createTestService (t, schema, resolvers = {}) { 10 | const service = Fastify() 11 | service.register(mercuriusFederationPlugin, { 12 | schema, 13 | resolvers 14 | }) 15 | await service.listen({ port: 0 }) 16 | return [service, service.server.address().port] 17 | } 18 | 19 | const users = { 20 | u1: { 21 | id: 'u1', 22 | name: 'John' 23 | }, 24 | u2: { 25 | id: 'u2', 26 | name: 'Jane' 27 | } 28 | } 29 | 30 | const posts = { 31 | p1: { 32 | pid: 'p1', 33 | title: 'Post 1', 34 | content: 'Content 1', 35 | authorId: 'u1' 36 | }, 37 | p2: { 38 | pid: 'p2', 39 | title: 'Post 2', 40 | content: 'Content 2', 41 | authorId: 'u2' 42 | }, 43 | p3: { 44 | pid: 'p3', 45 | title: 'Post 3', 46 | content: 'Content 3', 47 | authorId: 'u1' 48 | }, 49 | p4: { 50 | pid: 'p4', 51 | title: 'Post 4', 52 | content: 'Content 4', 53 | authorId: 'u1' 54 | } 55 | } 56 | 57 | async function createTestGatewayServer (t, validationOptions) { 58 | // User service 59 | const userServiceSchema = ` 60 | ${mercuriusValidation.graphQLTypeDefs} 61 | 62 | type Query @extends { 63 | me(id: Int @constraint(minimum: 1)): User 64 | } 65 | 66 | type User @key(fields: "id") { 67 | id: ID! 68 | name: String 69 | }` 70 | 71 | const userServiceResolvers = { 72 | Query: { 73 | me: (root, args, context, info) => { 74 | return users.u1 75 | } 76 | }, 77 | User: { 78 | __resolveReference: (user, args, context, info) => { 79 | return users[user.id] 80 | } 81 | } 82 | } 83 | 84 | const [userService, userServicePort] = await createTestService(t, userServiceSchema, userServiceResolvers) 85 | 86 | // Post service 87 | const postServiceSchema = ` 88 | ${mercuriusValidation.graphQLTypeDefs} 89 | 90 | type Post @key(fields: "pid") { 91 | pid: ID! 92 | author: User 93 | } 94 | 95 | extend type Query { 96 | topPosts(count: Int @constraint(minimum: 1)): [Post] 97 | } 98 | 99 | type User @key(fields: "id") @extends { 100 | id: ID! @external 101 | topPosts(count: Int!): [Post] 102 | topPostsFloat(count: Float!): [Post] 103 | }` 104 | 105 | const postServiceResolvers = { 106 | Post: { 107 | __resolveReference: (post, args, context, info) => { 108 | return posts[post.pid] 109 | }, 110 | author: (post, args, context, info) => { 111 | return { 112 | __typename: 'User', 113 | id: post.authorId 114 | } 115 | } 116 | }, 117 | User: { 118 | topPosts: (user, { count }, context, info) => { 119 | return Object.values(posts).filter(p => p.authorId === user.id).slice(0, count) 120 | }, 121 | topPostsFloat: (user, { count }, context, info) => { 122 | return Object.values(posts).filter(p => p.authorId === user.id).slice(0, count) 123 | } 124 | }, 125 | Query: { 126 | topPosts: (root, { count = 2 }) => Object.values(posts).slice(0, count) 127 | } 128 | } 129 | 130 | const [postService, postServicePort] = await createTestService(t, postServiceSchema, postServiceResolvers) 131 | 132 | const gateway = Fastify() 133 | t.teardown(async () => { 134 | await gateway.close() 135 | await userService.close() 136 | await postService.close() 137 | }) 138 | gateway.register(mercuriusGateway, { 139 | gateway: { 140 | services: [{ 141 | name: 'user', 142 | url: `http://127.0.0.1:${userServicePort}/graphql` 143 | }, { 144 | name: 'post', 145 | url: `http://127.0.0.1:${postServicePort}/graphql` 146 | }] 147 | } 148 | }) 149 | 150 | gateway.register(mercuriusValidation, validationOptions || { 151 | schema: { 152 | User: { 153 | topPosts: { 154 | count: { type: 'integer', minimum: 1 } 155 | }, 156 | topPostsFloat: { 157 | count: { type: 'number', minimum: 1 } 158 | } 159 | } 160 | } 161 | }) 162 | return gateway 163 | } 164 | 165 | t.test('Gateway validation', t => { 166 | t.plan(2) 167 | 168 | t.test('gateway - should protect the schema as normal if everything is okay', async (t) => { 169 | t.plan(1) 170 | 171 | const app = await createTestGatewayServer(t) 172 | 173 | const query = ` 174 | query { 175 | me(id: 1) { 176 | id 177 | name 178 | nickname: name 179 | topPosts(count: 2) { 180 | pid 181 | author { 182 | id 183 | } 184 | } 185 | topPostsFloat(count: 2) { 186 | pid 187 | author { 188 | id 189 | } 190 | } 191 | } 192 | topPosts(count: 2) { 193 | pid 194 | } 195 | }` 196 | 197 | const res = await app.inject({ 198 | method: 'POST', 199 | headers: { 'content-type': 'application/json' }, 200 | url: '/graphql', 201 | body: JSON.stringify({ query }) 202 | }) 203 | 204 | t.same(JSON.parse(res.body), { 205 | data: { 206 | me: { 207 | id: 'u1', 208 | name: 'John', 209 | nickname: 'John', 210 | topPosts: [ 211 | { 212 | pid: 'p1', 213 | author: { 214 | id: 'u1' 215 | } 216 | }, 217 | { 218 | pid: 'p3', 219 | author: { 220 | id: 'u1' 221 | } 222 | } 223 | ], 224 | topPostsFloat: [ 225 | { 226 | pid: 'p1', 227 | author: { 228 | id: 'u1' 229 | } 230 | }, 231 | { 232 | pid: 'p3', 233 | author: { 234 | id: 'u1' 235 | } 236 | } 237 | ] 238 | }, 239 | topPosts: [ 240 | { 241 | pid: 'p1' 242 | }, 243 | { 244 | pid: 'p2' 245 | } 246 | ] 247 | } 248 | }) 249 | }) 250 | 251 | t.test('gateway - should protect the schema if everything is not okay', async (t) => { 252 | t.plan(1) 253 | const app = await createTestGatewayServer(t) 254 | 255 | const query = `query { 256 | invalidId: me(id: 0) { 257 | id 258 | } 259 | me(id: 1) { 260 | id 261 | name 262 | nickname: name 263 | topPosts(count: -1) { 264 | pid 265 | author { 266 | id 267 | } 268 | } 269 | } 270 | topPosts(count: -2) { 271 | pid 272 | } 273 | }` 274 | 275 | const res = await app.inject({ 276 | method: 'POST', 277 | headers: { 'content-type': 'application/json' }, 278 | url: '/graphql', 279 | body: JSON.stringify({ query }) 280 | }) 281 | 282 | t.same(JSON.parse(res.body), { 283 | data: { 284 | invalidId: null, 285 | me: { 286 | id: 'u1', 287 | name: 'John', 288 | nickname: 'John', 289 | topPosts: null 290 | }, 291 | topPosts: null 292 | }, 293 | errors: [ 294 | { 295 | message: "Failed Validation on arguments for field 'Query.me'", 296 | locations: [ 297 | { 298 | line: 2, 299 | column: 7 300 | } 301 | ], 302 | path: [ 303 | 'invalidId' 304 | ], 305 | extensions: { 306 | code: 'MER_VALIDATION_ERR_FAILED_VALIDATION', 307 | name: 'ValidationError', 308 | details: [ 309 | { 310 | instancePath: '/id', 311 | schemaPath: '#/properties/id/minimum', 312 | keyword: 'minimum', 313 | params: { 314 | comparison: '>=', 315 | limit: 1 316 | }, 317 | message: 'must be >= 1', 318 | schema: 1, 319 | parentSchema: { 320 | $id: 'https://mercurius.dev/validation/Query/me/id', 321 | type: ['integer', 'null'], 322 | minimum: 1 323 | }, 324 | data: 0 325 | } 326 | ] 327 | } 328 | }, 329 | { 330 | message: "Failed Validation on arguments for field 'Query.topPosts'", 331 | locations: [ 332 | { 333 | line: 16, 334 | column: 7 335 | } 336 | ], 337 | path: [ 338 | 'topPosts' 339 | ], 340 | extensions: { 341 | code: 'MER_VALIDATION_ERR_FAILED_VALIDATION', 342 | name: 'ValidationError', 343 | details: [ 344 | { 345 | instancePath: '/count', 346 | schemaPath: '#/properties/count/minimum', 347 | keyword: 'minimum', 348 | params: { 349 | comparison: '>=', 350 | limit: 1 351 | }, 352 | message: 'must be >= 1', 353 | schema: 1, 354 | parentSchema: { 355 | $id: 'https://mercurius.dev/validation/Query/topPosts/count', 356 | type: ['integer', 'null'], 357 | minimum: 1 358 | }, 359 | data: -2 360 | } 361 | ] 362 | } 363 | }, 364 | { 365 | message: "Failed Validation on arguments for field 'User.topPosts'", 366 | locations: [ 367 | { 368 | line: 9, 369 | column: 9 370 | } 371 | ], 372 | path: [ 373 | 'me', 374 | 'topPosts' 375 | ], 376 | extensions: { 377 | code: 'MER_VALIDATION_ERR_FAILED_VALIDATION', 378 | name: 'ValidationError', 379 | details: [ 380 | { 381 | instancePath: '/count', 382 | schemaPath: '#/properties/count/minimum', 383 | keyword: 'minimum', 384 | params: { 385 | comparison: '>=', 386 | limit: 1 387 | }, 388 | message: 'must be >= 1', 389 | schema: 1, 390 | parentSchema: { 391 | $id: 'https://mercurius.dev/validation/User/topPosts/count', 392 | type: 'integer', 393 | minimum: 1 394 | }, 395 | data: -1 396 | } 397 | ] 398 | } 399 | } 400 | ] 401 | }) 402 | }) 403 | }) 404 | -------------------------------------------------------------------------------- /test/jtd-validation.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const t = require('tap') 4 | const Fastify = require('fastify') 5 | const mercurius = require('mercurius') 6 | const mercuriusValidation = require('..') 7 | 8 | const schema = ` 9 | type Message { 10 | id: ID! 11 | text: String 12 | } 13 | 14 | input Filters { 15 | id: ID 16 | text: String 17 | } 18 | 19 | input NestedFilters { 20 | input: Filters 21 | } 22 | 23 | input ArrayFilters { 24 | values: [Int] 25 | filters: [Filters] 26 | } 27 | 28 | type Query { 29 | noResolver(id: Int): Int 30 | message(id: Int): Message 31 | messages( 32 | filters: Filters 33 | nestedFilters: NestedFilters 34 | arrayScalarFilters: [Int] 35 | arrayObjectFilters: [ArrayFilters] 36 | ): [Message] 37 | } 38 | ` 39 | 40 | const messages = [ 41 | { 42 | id: 0, 43 | text: 'Some system message.' 44 | }, 45 | { 46 | id: 1, 47 | text: 'Hello there' 48 | }, 49 | { 50 | id: 2, 51 | text: 'Give me a place to stand, a lever long enough and a fulcrum. And I can move the Earth.' 52 | }, 53 | { 54 | id: 3, 55 | text: '' 56 | } 57 | ] 58 | 59 | const resolvers = { 60 | Query: { 61 | message: async (_, { id }) => { 62 | return messages.find(message => message.id === Number(id)) 63 | }, 64 | messages: async () => { 65 | return messages 66 | } 67 | } 68 | } 69 | 70 | t.test('JTD validators', t => { 71 | t.plan(10) 72 | 73 | t.test('should protect the schema and not affect operations when everything is okay', async (t) => { 74 | t.plan(1) 75 | 76 | const app = Fastify() 77 | t.teardown(app.close.bind(app)) 78 | 79 | app.register(mercurius, { 80 | schema, 81 | resolvers 82 | }) 83 | app.register(mercuriusValidation, { 84 | mode: 'JTD', 85 | schema: { 86 | Filters: { 87 | text: { enum: ['hello', 'there'] } 88 | }, 89 | Query: { 90 | message: { 91 | id: { type: 'int16' } 92 | } 93 | } 94 | } 95 | }) 96 | 97 | const query = `query { 98 | noResolver(id: 1) 99 | message(id: 1) { 100 | id 101 | text 102 | } 103 | messages( 104 | filters: { text: "hello" } 105 | nestedFilters: { input: { text: "hello" } } 106 | arrayScalarFilters: [1, 2] 107 | ) { 108 | id 109 | text 110 | } 111 | }` 112 | 113 | const response = await app.inject({ 114 | method: 'POST', 115 | headers: { 'content-type': 'application/json' }, 116 | url: '/graphql', 117 | body: JSON.stringify({ query }) 118 | }) 119 | 120 | t.same(JSON.parse(response.body), { 121 | data: { 122 | noResolver: null, 123 | message: { 124 | id: '1', 125 | text: 'Hello there' 126 | }, 127 | messages: [ 128 | { 129 | id: '0', 130 | text: 'Some system message.' 131 | }, 132 | { 133 | id: '1', 134 | text: 'Hello there' 135 | }, 136 | { 137 | id: '2', 138 | text: 'Give me a place to stand, a lever long enough and a fulcrum. And I can move the Earth.' 139 | }, 140 | { 141 | id: '3', 142 | text: '' 143 | } 144 | ] 145 | } 146 | }) 147 | }) 148 | 149 | t.test('should protect the schema arguments and error accordingly', async (t) => { 150 | t.plan(1) 151 | 152 | const app = Fastify() 153 | t.teardown(app.close.bind(app)) 154 | 155 | app.register(mercurius, { 156 | schema, 157 | resolvers 158 | }) 159 | app.register(mercuriusValidation, { 160 | mode: 'JTD', 161 | schema: { 162 | Query: { 163 | message: { 164 | id: { type: 'int16' } 165 | } 166 | } 167 | } 168 | }) 169 | 170 | const query = `query { 171 | message(id: 32768) { 172 | id 173 | text 174 | } 175 | }` 176 | 177 | const response = await app.inject({ 178 | method: 'POST', 179 | headers: { 'content-type': 'application/json' }, 180 | url: '/graphql', 181 | body: JSON.stringify({ query }) 182 | }) 183 | 184 | t.same(JSON.parse(response.body), { 185 | data: { 186 | message: null 187 | }, 188 | errors: [ 189 | { 190 | message: "Failed Validation on arguments for field 'Query.message'", 191 | locations: [{ 192 | line: 2, 193 | column: 7 194 | }], 195 | path: [ 196 | 'message' 197 | ], 198 | extensions: { 199 | code: 'MER_VALIDATION_ERR_FAILED_VALIDATION', 200 | name: 'ValidationError', 201 | details: [ 202 | { 203 | instancePath: '/id', 204 | schemaPath: '/optionalProperties/id/type', 205 | keyword: 'type', 206 | params: { 207 | type: 'int16', 208 | nullable: false 209 | }, 210 | message: 'must be int16', 211 | schema: 'int16', 212 | parentSchema: { 213 | type: 'int16' 214 | }, 215 | data: 32768 216 | } 217 | ] 218 | } 219 | } 220 | ] 221 | }) 222 | }) 223 | 224 | t.test('should protect the schema input types and error accordingly', async (t) => { 225 | t.plan(1) 226 | 227 | const app = Fastify() 228 | t.teardown(app.close.bind(app)) 229 | 230 | app.register(mercurius, { 231 | schema, 232 | resolvers 233 | }) 234 | app.register(mercuriusValidation, { 235 | mode: 'JTD', 236 | schema: { 237 | Filters: { 238 | text: { enum: ['hello', 'there'] } 239 | }, 240 | Query: { 241 | message: { 242 | id: { type: 'int16' } 243 | } 244 | } 245 | } 246 | }) 247 | 248 | const query = `query { 249 | messages(filters: { text: "wrong"}) { 250 | id 251 | text 252 | } 253 | }` 254 | 255 | const response = await app.inject({ 256 | method: 'POST', 257 | headers: { 'content-type': 'application/json' }, 258 | url: '/graphql', 259 | body: JSON.stringify({ query }) 260 | }) 261 | 262 | t.same(JSON.parse(response.body), { 263 | data: { 264 | messages: null 265 | }, 266 | errors: [ 267 | { 268 | message: "Failed Validation on arguments for field 'Query.messages'", 269 | locations: [{ 270 | line: 2, 271 | column: 7 272 | }], 273 | path: [ 274 | 'messages' 275 | ], 276 | extensions: { 277 | code: 'MER_VALIDATION_ERR_FAILED_VALIDATION', 278 | name: 'ValidationError', 279 | details: [ 280 | { 281 | instancePath: '/filters/text', 282 | schemaPath: '/definitions/Filters/optionalProperties/text/enum', 283 | keyword: 'enum', 284 | params: { 285 | allowedValues: [ 286 | 'hello', 287 | 'there' 288 | ] 289 | }, 290 | message: 'must be equal to one of the allowed values', 291 | schema: [ 292 | 'hello', 293 | 'there' 294 | ], 295 | parentSchema: { 296 | enum: [ 297 | 'hello', 298 | 'there' 299 | ] 300 | }, 301 | data: 'wrong' 302 | } 303 | ] 304 | } 305 | } 306 | ] 307 | }) 308 | }) 309 | 310 | t.test('should protect the schema input types with nested types and error accordingly', async (t) => { 311 | t.plan(1) 312 | 313 | const app = Fastify() 314 | t.teardown(app.close.bind(app)) 315 | 316 | app.register(mercurius, { 317 | schema, 318 | resolvers 319 | }) 320 | app.register(mercuriusValidation, { 321 | mode: 'JTD', 322 | schema: { 323 | Filters: { 324 | text: { enum: ['hello', 'there'] } 325 | }, 326 | Query: { 327 | message: { 328 | id: { type: 'int16' } 329 | } 330 | } 331 | } 332 | }) 333 | 334 | const query = `query { 335 | messages(filters: { text: "hello"}, nestedFilters: { input: { text: "wrong" }}) { 336 | id 337 | text 338 | } 339 | }` 340 | 341 | const response = await app.inject({ 342 | method: 'POST', 343 | headers: { 'content-type': 'application/json' }, 344 | url: '/graphql', 345 | body: JSON.stringify({ query }) 346 | }) 347 | 348 | t.same(JSON.parse(response.body), { 349 | data: { 350 | messages: null 351 | }, 352 | errors: [ 353 | { 354 | message: "Failed Validation on arguments for field 'Query.messages'", 355 | locations: [{ 356 | line: 2, 357 | column: 7 358 | }], 359 | path: [ 360 | 'messages' 361 | ], 362 | extensions: { 363 | code: 'MER_VALIDATION_ERR_FAILED_VALIDATION', 364 | name: 'ValidationError', 365 | details: [ 366 | { 367 | instancePath: '/nestedFilters/input/text', 368 | schemaPath: '/definitions/Filters/optionalProperties/text/enum', 369 | keyword: 'enum', 370 | params: { 371 | allowedValues: [ 372 | 'hello', 373 | 'there' 374 | ] 375 | }, 376 | message: 'must be equal to one of the allowed values', 377 | schema: [ 378 | 'hello', 379 | 'there' 380 | ], 381 | parentSchema: { 382 | enum: [ 383 | 'hello', 384 | 'there' 385 | ] 386 | }, 387 | data: 'wrong' 388 | } 389 | ] 390 | } 391 | } 392 | ] 393 | }) 394 | }) 395 | 396 | t.test('should protect schema list scalar types and error accordingly', async t => { 397 | t.plan(1) 398 | 399 | const app = Fastify() 400 | t.teardown(app.close.bind(app)) 401 | 402 | app.register(mercurius, { 403 | schema, 404 | resolvers 405 | }) 406 | app.register(mercuriusValidation, { 407 | mode: 'JTD', 408 | schema: { 409 | ArrayFilters: { 410 | values: { 411 | elements: { 412 | type: 'int16' 413 | } 414 | } 415 | }, 416 | Query: { 417 | messages: { 418 | arrayScalarFilters: { 419 | elements: { 420 | type: 'int16' 421 | } 422 | } 423 | } 424 | } 425 | } 426 | }) 427 | 428 | const query = `query { 429 | messages(arrayScalarFilters: [32768]) { 430 | id 431 | text 432 | } 433 | }` 434 | 435 | const response = await app.inject({ 436 | method: 'POST', 437 | headers: { 'content-type': 'application/json' }, 438 | url: '/graphql', 439 | body: JSON.stringify({ query }) 440 | }) 441 | 442 | t.same(JSON.parse(response.body), { 443 | data: { 444 | messages: null 445 | }, 446 | errors: [ 447 | { 448 | message: "Failed Validation on arguments for field 'Query.messages'", 449 | locations: [{ 450 | line: 2, 451 | column: 7 452 | }], 453 | path: [ 454 | 'messages' 455 | ], 456 | extensions: { 457 | code: 'MER_VALIDATION_ERR_FAILED_VALIDATION', 458 | name: 'ValidationError', 459 | details: [ 460 | { 461 | instancePath: '/arrayScalarFilters/0', 462 | schemaPath: '/optionalProperties/arrayScalarFilters/elements/type', 463 | keyword: 'type', 464 | params: { 465 | type: 'int16', 466 | nullable: false 467 | }, 468 | message: 'must be int16', 469 | schema: 'int16', 470 | parentSchema: { 471 | type: 'int16' 472 | }, 473 | data: 32768 474 | } 475 | ] 476 | } 477 | } 478 | ] 479 | }) 480 | }) 481 | 482 | t.test('should protect schema list input object types and error accordingly', async t => { 483 | t.plan(1) 484 | 485 | const app = Fastify() 486 | t.teardown(app.close.bind(app)) 487 | 488 | app.register(mercurius, { 489 | schema, 490 | resolvers 491 | }) 492 | app.register(mercuriusValidation, { 493 | mode: 'JTD', 494 | schema: { 495 | Filters: { 496 | text: { enum: ['hello', 'there'] } 497 | }, 498 | ArrayFilters: { 499 | values: { 500 | elements: { 501 | type: 'int16' 502 | } 503 | } 504 | } 505 | } 506 | }) 507 | 508 | const query = `query { 509 | messages(arrayObjectFilters: [{ values: [32768], filters: [{ text: "" }]}]) { 510 | id 511 | text 512 | } 513 | }` 514 | 515 | const response = await app.inject({ 516 | method: 'POST', 517 | headers: { 'content-type': 'application/json' }, 518 | url: '/graphql', 519 | body: JSON.stringify({ query }) 520 | }) 521 | 522 | t.same(JSON.parse(response.body), { 523 | data: { 524 | messages: null 525 | }, 526 | errors: [ 527 | { 528 | message: "Failed Validation on arguments for field 'Query.messages'", 529 | locations: [{ 530 | line: 2, 531 | column: 7 532 | }], 533 | path: [ 534 | 'messages' 535 | ], 536 | extensions: { 537 | code: 'MER_VALIDATION_ERR_FAILED_VALIDATION', 538 | name: 'ValidationError', 539 | details: [ 540 | { 541 | instancePath: '/arrayObjectFilters/0/values/0', 542 | schemaPath: '/definitions/ArrayFilters/optionalProperties/values/elements/type', 543 | keyword: 'type', 544 | params: { 545 | type: 'int16', 546 | nullable: false 547 | }, 548 | message: 'must be int16', 549 | schema: 'int16', 550 | parentSchema: { 551 | type: 'int16' 552 | }, 553 | data: 32768 554 | }, 555 | { 556 | instancePath: '/arrayObjectFilters/0/filters/0/text', 557 | schemaPath: '/definitions/Filters/optionalProperties/text/enum', 558 | keyword: 'enum', 559 | params: { 560 | allowedValues: [ 561 | 'hello', 562 | 'there' 563 | ] 564 | }, 565 | message: 'must be equal to one of the allowed values', 566 | schema: [ 567 | 'hello', 568 | 'there' 569 | ], 570 | parentSchema: { 571 | enum: [ 572 | 'hello', 573 | 'there' 574 | ] 575 | }, 576 | data: '' 577 | } 578 | ] 579 | } 580 | } 581 | ] 582 | }) 583 | }) 584 | 585 | t.test('should protect schema non-null types and error accordingly', async t => { 586 | t.plan(1) 587 | 588 | const app = Fastify() 589 | t.teardown(app.close.bind(app)) 590 | 591 | const schema = ` 592 | type Message { 593 | id: ID! 594 | text: String 595 | } 596 | 597 | input Filters { 598 | id: ID 599 | text: String! 600 | } 601 | 602 | input NestedFilters { 603 | input: Filters! 604 | } 605 | 606 | input ArrayFilters { 607 | values: [String!]! 608 | filters: [Filters!]! 609 | } 610 | 611 | type Query { 612 | message(id: ID!): Message 613 | messages( 614 | filters: Filters! 615 | nestedFilters: NestedFilters! 616 | arrayScalarFilters: [String!]! 617 | arrayObjectFilters: [ArrayFilters!]! 618 | ): [Message] 619 | } 620 | ` 621 | 622 | app.register(mercurius, { 623 | schema, 624 | resolvers 625 | }) 626 | app.register(mercuriusValidation, { 627 | mode: 'JTD', 628 | schema: { 629 | Filters: { 630 | text: { enum: ['hello', 'there'] } 631 | }, 632 | Query: { 633 | message: { 634 | id: { enum: ['hello', 'there'] } 635 | }, 636 | messages: { 637 | arrayScalarFilters: { 638 | elements: { enum: ['hello', 'there'] } 639 | } 640 | } 641 | } 642 | } 643 | }) 644 | 645 | const query = `query { 646 | message(id: "") { 647 | id 648 | text 649 | } 650 | messages( 651 | filters: { text: ""} 652 | nestedFilters: { input: { text: ""} } 653 | arrayScalarFilters: [""] 654 | arrayObjectFilters: [{ values: [""], filters: { text: "" }}] 655 | ) { 656 | id 657 | text 658 | } 659 | }` 660 | 661 | const response = await app.inject({ 662 | method: 'POST', 663 | headers: { 'content-type': 'application/json' }, 664 | url: '/graphql', 665 | body: JSON.stringify({ query }) 666 | }) 667 | 668 | t.same(JSON.parse(response.body), { 669 | data: { 670 | message: null, 671 | messages: null 672 | }, 673 | errors: [ 674 | { 675 | message: "Failed Validation on arguments for field 'Query.message'", 676 | locations: [ 677 | { 678 | line: 2, 679 | column: 7 680 | } 681 | ], 682 | path: [ 683 | 'message' 684 | ], 685 | extensions: { 686 | code: 'MER_VALIDATION_ERR_FAILED_VALIDATION', 687 | name: 'ValidationError', 688 | details: [ 689 | { 690 | instancePath: '/id', 691 | schemaPath: '/optionalProperties/id/enum', 692 | keyword: 'enum', 693 | params: { 694 | allowedValues: [ 695 | 'hello', 696 | 'there' 697 | ] 698 | }, 699 | message: 'must be equal to one of the allowed values', 700 | schema: [ 701 | 'hello', 702 | 'there' 703 | ], 704 | parentSchema: { 705 | enum: [ 706 | 'hello', 707 | 'there' 708 | ] 709 | }, 710 | data: '' 711 | } 712 | ] 713 | } 714 | }, 715 | { 716 | message: "Failed Validation on arguments for field 'Query.messages'", 717 | locations: [ 718 | { 719 | line: 6, 720 | column: 7 721 | } 722 | ], 723 | path: [ 724 | 'messages' 725 | ], 726 | extensions: { 727 | code: 'MER_VALIDATION_ERR_FAILED_VALIDATION', 728 | name: 'ValidationError', 729 | details: [ 730 | { 731 | instancePath: '/filters/text', 732 | schemaPath: '/definitions/Filters/optionalProperties/text/enum', 733 | keyword: 'enum', 734 | params: { 735 | allowedValues: [ 736 | 'hello', 737 | 'there' 738 | ] 739 | }, 740 | message: 'must be equal to one of the allowed values', 741 | schema: [ 742 | 'hello', 743 | 'there' 744 | ], 745 | parentSchema: { 746 | enum: [ 747 | 'hello', 748 | 'there' 749 | ] 750 | }, 751 | data: '' 752 | }, 753 | { 754 | instancePath: '/nestedFilters/input/text', 755 | schemaPath: '/definitions/Filters/optionalProperties/text/enum', 756 | keyword: 'enum', 757 | params: { 758 | allowedValues: [ 759 | 'hello', 760 | 'there' 761 | ] 762 | }, 763 | message: 'must be equal to one of the allowed values', 764 | schema: [ 765 | 'hello', 766 | 'there' 767 | ], 768 | parentSchema: { 769 | enum: [ 770 | 'hello', 771 | 'there' 772 | ] 773 | }, 774 | data: '' 775 | }, 776 | { 777 | instancePath: '/arrayScalarFilters/0', 778 | schemaPath: '/optionalProperties/arrayScalarFilters/elements/enum', 779 | keyword: 'enum', 780 | params: { 781 | allowedValues: [ 782 | 'hello', 783 | 'there' 784 | ] 785 | }, 786 | message: 'must be equal to one of the allowed values', 787 | schema: [ 788 | 'hello', 789 | 'there' 790 | ], 791 | parentSchema: { 792 | enum: [ 793 | 'hello', 794 | 'there' 795 | ] 796 | }, 797 | data: '' 798 | }, 799 | { 800 | instancePath: '/arrayObjectFilters/0/filters/0/text', 801 | schemaPath: '/definitions/Filters/optionalProperties/text/enum', 802 | keyword: 'enum', 803 | params: { 804 | allowedValues: [ 805 | 'hello', 806 | 'there' 807 | ] 808 | }, 809 | message: 'must be equal to one of the allowed values', 810 | schema: [ 811 | 'hello', 812 | 'there' 813 | ], 814 | parentSchema: { 815 | enum: [ 816 | 'hello', 817 | 'there' 818 | ] 819 | }, 820 | data: '' 821 | } 822 | ] 823 | } 824 | } 825 | ] 826 | }) 827 | }) 828 | 829 | t.test('should protect schema fields that have arguments but no associated resolver', async t => { 830 | t.plan(1) 831 | 832 | const app = Fastify() 833 | t.teardown(app.close.bind(app)) 834 | 835 | app.register(mercurius, { 836 | schema, 837 | resolvers 838 | }) 839 | app.register(mercuriusValidation, { 840 | mode: 'JTD', 841 | schema: { 842 | Query: { 843 | noResolver: { 844 | id: { type: 'int16' } 845 | } 846 | } 847 | } 848 | }) 849 | 850 | const query = `query { 851 | noResolver(id: 32768) 852 | }` 853 | 854 | const response = await app.inject({ 855 | method: 'POST', 856 | headers: { 'content-type': 'application/json' }, 857 | url: '/graphql', 858 | body: JSON.stringify({ query }) 859 | }) 860 | 861 | t.same(JSON.parse(response.body), { 862 | data: { 863 | noResolver: null 864 | }, 865 | errors: [ 866 | { 867 | message: "Failed Validation on arguments for field 'Query.noResolver'", 868 | locations: [ 869 | { 870 | line: 2, 871 | column: 7 872 | } 873 | ], 874 | path: [ 875 | 'noResolver' 876 | ], 877 | extensions: { 878 | code: 'MER_VALIDATION_ERR_FAILED_VALIDATION', 879 | name: 'ValidationError', 880 | details: [ 881 | { 882 | instancePath: '/id', 883 | schemaPath: '/optionalProperties/id/type', 884 | keyword: 'type', 885 | params: { 886 | type: 'int16', 887 | nullable: false 888 | }, 889 | message: 'must be int16', 890 | schema: 'int16', 891 | parentSchema: { 892 | type: 'int16' 893 | }, 894 | data: 32768 895 | } 896 | ] 897 | } 898 | } 899 | ] 900 | }) 901 | }) 902 | 903 | t.test('should protect at the input object type level and error accordingly', async t => { 904 | t.plan(1) 905 | 906 | const app = Fastify() 907 | t.teardown(app.close.bind(app)) 908 | 909 | const schema = ` 910 | type Message { 911 | id: ID! 912 | text: String 913 | } 914 | 915 | input Filters { 916 | id: ID 917 | } 918 | 919 | type Query { 920 | noResolver(id: Int): Int 921 | message(id: Int): Message 922 | messages(filters: Filters): [Message] 923 | } 924 | ` 925 | 926 | app.register(mercurius, { 927 | schema, 928 | resolvers 929 | }) 930 | app.register(mercuriusValidation, { 931 | mode: 'JTD', 932 | schema: { 933 | Filters: { 934 | __typeValidation: { 935 | values: { 936 | type: 'uint8' 937 | } 938 | } 939 | } 940 | } 941 | }) 942 | 943 | const query = `query { 944 | messages(filters: { id: 256 }) { 945 | id 946 | text 947 | } 948 | }` 949 | 950 | const response = await app.inject({ 951 | method: 'POST', 952 | headers: { 'content-type': 'application/json' }, 953 | url: '/graphql', 954 | body: JSON.stringify({ query }) 955 | }) 956 | 957 | t.same(JSON.parse(response.body), { 958 | data: { 959 | messages: null 960 | }, 961 | errors: [ 962 | { 963 | message: "Failed Validation on arguments for field 'Query.messages'", 964 | locations: [ 965 | { 966 | line: 2, 967 | column: 7 968 | } 969 | ], 970 | path: [ 971 | 'messages' 972 | ], 973 | extensions: { 974 | code: 'MER_VALIDATION_ERR_FAILED_VALIDATION', 975 | name: 'ValidationError', 976 | details: [ 977 | { 978 | instancePath: '/filters/id', 979 | schemaPath: '/definitions/Filters/values/type', 980 | keyword: 'type', 981 | params: { 982 | type: 'uint8', 983 | nullable: false 984 | }, 985 | message: 'must be uint8', 986 | schema: 'uint8', 987 | parentSchema: { 988 | type: 'uint8' 989 | }, 990 | data: '256' 991 | } 992 | ] 993 | } 994 | } 995 | ] 996 | }) 997 | }) 998 | 999 | t.test('should support custom AJV options', async t => { 1000 | t.plan(1) 1001 | 1002 | const app = Fastify() 1003 | t.teardown(app.close.bind(app)) 1004 | 1005 | const schema = ` 1006 | type Message { 1007 | id: ID! 1008 | text: String 1009 | } 1010 | 1011 | input Filters { 1012 | id: ID 1013 | text: String! 1014 | } 1015 | 1016 | input NestedFilters { 1017 | input: Filters! 1018 | } 1019 | 1020 | input ArrayFilters { 1021 | values: [String!]! 1022 | filters: [Filters!]! 1023 | } 1024 | 1025 | type Query { 1026 | message(id: ID!): Message 1027 | messages( 1028 | filters: Filters! 1029 | nestedFilters: NestedFilters! 1030 | arrayScalarFilters: [String!]! 1031 | arrayObjectFilters: [ArrayFilters!]! 1032 | ): [Message] 1033 | } 1034 | ` 1035 | 1036 | app.register(mercurius, { 1037 | schema, 1038 | resolvers 1039 | }) 1040 | app.register(mercuriusValidation, { 1041 | mode: 'JTD', 1042 | allErrors: false, 1043 | schema: { 1044 | Filters: { 1045 | text: { enum: ['hello', 'there'] } 1046 | }, 1047 | Query: { 1048 | message: { 1049 | id: { enum: ['hello', 'there'] } 1050 | }, 1051 | messages: { 1052 | arrayScalarFilters: { 1053 | elements: { enum: ['hello', 'there'] } 1054 | } 1055 | } 1056 | } 1057 | } 1058 | }) 1059 | 1060 | const query = `query { 1061 | message(id: "") { 1062 | id 1063 | text 1064 | } 1065 | messages( 1066 | filters: { text: ""} 1067 | nestedFilters: { input: { text: ""} } 1068 | arrayScalarFilters: [""] 1069 | arrayObjectFilters: [{ values: [""], filters: { text: "" }}] 1070 | ) { 1071 | id 1072 | text 1073 | } 1074 | }` 1075 | 1076 | const response = await app.inject({ 1077 | method: 'POST', 1078 | headers: { 'content-type': 'application/json' }, 1079 | url: '/graphql', 1080 | body: JSON.stringify({ query }) 1081 | }) 1082 | 1083 | t.same(JSON.parse(response.body), { 1084 | data: { 1085 | message: null, 1086 | messages: null 1087 | }, 1088 | errors: [ 1089 | { 1090 | message: "Failed Validation on arguments for field 'Query.message'", 1091 | locations: [ 1092 | { 1093 | line: 2, 1094 | column: 7 1095 | } 1096 | ], 1097 | path: [ 1098 | 'message' 1099 | ], 1100 | extensions: { 1101 | code: 'MER_VALIDATION_ERR_FAILED_VALIDATION', 1102 | name: 'ValidationError', 1103 | details: [ 1104 | { 1105 | instancePath: '/id', 1106 | schemaPath: '/optionalProperties/id/enum', 1107 | keyword: 'enum', 1108 | params: { 1109 | allowedValues: [ 1110 | 'hello', 1111 | 'there' 1112 | ] 1113 | }, 1114 | message: 'must be equal to one of the allowed values', 1115 | schema: [ 1116 | 'hello', 1117 | 'there' 1118 | ], 1119 | parentSchema: { 1120 | enum: [ 1121 | 'hello', 1122 | 'there' 1123 | ] 1124 | }, 1125 | data: '' 1126 | } 1127 | ] 1128 | } 1129 | }, 1130 | { 1131 | message: "Failed Validation on arguments for field 'Query.messages'", 1132 | locations: [ 1133 | { 1134 | line: 6, 1135 | column: 7 1136 | } 1137 | ], 1138 | path: [ 1139 | 'messages' 1140 | ], 1141 | extensions: { 1142 | code: 'MER_VALIDATION_ERR_FAILED_VALIDATION', 1143 | name: 'ValidationError', 1144 | details: [ 1145 | { 1146 | instancePath: '/filters/text', 1147 | schemaPath: '/definitions/Filters/optionalProperties/text/enum', 1148 | keyword: 'enum', 1149 | params: { 1150 | allowedValues: [ 1151 | 'hello', 1152 | 'there' 1153 | ] 1154 | }, 1155 | message: 'must be equal to one of the allowed values', 1156 | schema: [ 1157 | 'hello', 1158 | 'there' 1159 | ], 1160 | parentSchema: { 1161 | enum: [ 1162 | 'hello', 1163 | 'there' 1164 | ] 1165 | }, 1166 | data: '' 1167 | } 1168 | ] 1169 | } 1170 | } 1171 | ] 1172 | }) 1173 | }) 1174 | }) 1175 | -------------------------------------------------------------------------------- /test/refresh.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const t = require('tap') 4 | const FakeTimers = require('@sinonjs/fake-timers') 5 | const { promisify } = require('util') 6 | const Fastify = require('fastify') 7 | const { mercuriusFederationPlugin, buildFederationSchema } = require('@mercuriusjs/federation') 8 | const mercuriusGateway = require('@mercuriusjs/gateway') 9 | const mercuriusValidation = require('..') 10 | 11 | const immediate = promisify(setImmediate) 12 | 13 | const schema = ` 14 | ${mercuriusValidation.graphQLTypeDefs} 15 | 16 | type Message @key(fields: "id") { 17 | id: ID! 18 | text: String 19 | } 20 | 21 | input Filters { 22 | id: ID 23 | text: String 24 | } 25 | 26 | extend type Query { 27 | message(id: ID @constraint(type: "string" minLength: 1)): Message 28 | messages(filters: Filters): [Message] 29 | } 30 | ` 31 | 32 | const messages = [ 33 | { 34 | id: 0, 35 | text: 'Some system message.' 36 | }, 37 | { 38 | id: 1, 39 | text: 'Hello there' 40 | }, 41 | { 42 | id: 2, 43 | text: 'Give me a place to stand, a lever long enough and a fulcrum. And I can move the Earth.' 44 | }, 45 | { 46 | id: 3, 47 | text: '' 48 | } 49 | ] 50 | 51 | const resolvers = { 52 | Query: { 53 | message: async (_, { id }) => { 54 | return messages.find(message => message.id === Number(id)) 55 | }, 56 | messages: async () => { 57 | return messages 58 | } 59 | } 60 | } 61 | 62 | t.test('gateway refresh', t => { 63 | t.plan(1) 64 | 65 | t.test('polling interval with a new schema should trigger refresh of schema policy build', async t => { 66 | t.plan(2) 67 | 68 | const clock = FakeTimers.install({ 69 | shouldAdvanceTime: true, 70 | advanceTimeDelta: 40 71 | }) 72 | t.teardown(() => clock.uninstall()) 73 | 74 | const messageService = Fastify() 75 | const gateway = Fastify() 76 | t.teardown(async () => { 77 | await gateway.close() 78 | await messageService.close() 79 | }) 80 | 81 | messageService.register(mercuriusFederationPlugin, { 82 | schema, 83 | resolvers 84 | }) 85 | 86 | await messageService.listen({ port: 0 }) 87 | 88 | const messageServicePort = messageService.server.address().port 89 | 90 | await gateway.register(mercuriusGateway, { 91 | gateway: { 92 | services: [ 93 | { 94 | name: 'message', 95 | url: `http://127.0.0.1:${messageServicePort}/graphql` 96 | } 97 | ], 98 | pollingInterval: 2000 99 | } 100 | }) 101 | await gateway.register(mercuriusValidation, {}) 102 | 103 | const query = `query { 104 | message(id: "") { 105 | id 106 | text 107 | } 108 | messages(filters: { text: ""}) { 109 | id 110 | text 111 | } 112 | }` 113 | 114 | { 115 | const res = await gateway.inject({ 116 | method: 'POST', 117 | headers: { 'content-type': 'application/json' }, 118 | url: '/graphql', 119 | body: JSON.stringify({ query }) 120 | }) 121 | 122 | t.same(JSON.parse(res.body), { 123 | data: { 124 | message: null, 125 | messages: [ 126 | { 127 | id: '0', 128 | text: 'Some system message.' 129 | }, 130 | { 131 | id: '1', 132 | text: 'Hello there' 133 | }, 134 | { 135 | id: '2', 136 | text: 'Give me a place to stand, a lever long enough and a fulcrum. And I can move the Earth.' 137 | }, 138 | { 139 | id: '3', 140 | text: '' 141 | } 142 | ] 143 | }, 144 | errors: [ 145 | { 146 | message: "Failed Validation on arguments for field 'Query.message'", 147 | locations: [{ 148 | line: 2, 149 | column: 7 150 | }], 151 | path: [ 152 | 'message' 153 | ], 154 | extensions: { 155 | code: 'MER_VALIDATION_ERR_FAILED_VALIDATION', 156 | name: 'ValidationError', 157 | details: [ 158 | { 159 | instancePath: '/id', 160 | schemaPath: '#/properties/id/minLength', 161 | keyword: 'minLength', 162 | params: { 163 | limit: 1 164 | }, 165 | message: 'must NOT have fewer than 1 characters', 166 | schema: 1, 167 | parentSchema: { 168 | $id: 'https://mercurius.dev/validation/Query/message/id', 169 | type: 'string', 170 | minLength: 1 171 | }, 172 | data: '' 173 | } 174 | ] 175 | } 176 | } 177 | ] 178 | }) 179 | } 180 | 181 | const newSchema = ` 182 | ${mercuriusValidation.graphQLTypeDefs} 183 | 184 | type Message @key(fields: "id") { 185 | id: ID! 186 | text: String 187 | } 188 | 189 | input Filters { 190 | id: ID 191 | text: String @constraint(type: "string" minLength: 1) 192 | } 193 | 194 | extend type Query { 195 | message(id: ID @constraint(type: "string" minLength: 1)): Message 196 | messages(filters: Filters): [Message] 197 | } 198 | ` 199 | messageService.graphql.replaceSchema(buildFederationSchema(newSchema)) 200 | messageService.graphql.defineResolvers(resolvers) 201 | 202 | await clock.tickAsync(2000) 203 | 204 | // We need the event loop to actually spin twice to 205 | // be able to propagate the change 206 | await immediate() 207 | await immediate() 208 | 209 | { 210 | const res = await gateway.inject({ 211 | method: 'POST', 212 | headers: { 'content-type': 'application/json' }, 213 | url: '/graphql', 214 | body: JSON.stringify({ query }) 215 | }) 216 | 217 | t.same(JSON.parse(res.body), { 218 | data: { 219 | message: null, 220 | messages: null 221 | }, 222 | errors: [ 223 | { 224 | message: "Failed Validation on arguments for field 'Query.message'", 225 | locations: [{ 226 | line: 2, 227 | column: 7 228 | }], 229 | path: [ 230 | 'message' 231 | ], 232 | extensions: { 233 | code: 'MER_VALIDATION_ERR_FAILED_VALIDATION', 234 | name: 'ValidationError', 235 | details: [ 236 | { 237 | instancePath: '/id', 238 | schemaPath: '#/properties/id/minLength', 239 | keyword: 'minLength', 240 | params: { 241 | limit: 1 242 | }, 243 | message: 'must NOT have fewer than 1 characters', 244 | schema: 1, 245 | parentSchema: { 246 | $id: 'https://mercurius.dev/validation/Query/message/id', 247 | type: 'string', 248 | minLength: 1 249 | }, 250 | data: '' 251 | } 252 | ] 253 | } 254 | }, 255 | { 256 | message: "Failed Validation on arguments for field 'Query.messages'", 257 | locations: [{ 258 | line: 6, 259 | column: 7 260 | }], 261 | path: [ 262 | 'messages' 263 | ], 264 | extensions: { 265 | code: 'MER_VALIDATION_ERR_FAILED_VALIDATION', 266 | name: 'ValidationError', 267 | details: [ 268 | { 269 | instancePath: '/filters/text', 270 | schemaPath: 'https://mercurius.dev/validation/Filters/properties/text/minLength', 271 | keyword: 'minLength', 272 | params: { 273 | limit: 1 274 | }, 275 | message: 'must NOT have fewer than 1 characters', 276 | schema: 1, 277 | parentSchema: { 278 | $id: 'https://mercurius.dev/validation/Filters/text', 279 | type: ['string', 'null'], 280 | minLength: 1 281 | }, 282 | data: '' 283 | } 284 | ] 285 | } 286 | } 287 | ] 288 | }) 289 | } 290 | }) 291 | }) 292 | -------------------------------------------------------------------------------- /test/registration.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const t = require('tap') 4 | const Fastify = require('fastify') 5 | const mercurius = require('mercurius') 6 | const { AssertionError } = require('assert') 7 | const mercuriusValidation = require('..') 8 | const { MER_VALIDATION_ERR_INVALID_OPTS } = require('../lib/errors') 9 | 10 | const schema = ` 11 | directive @constraint( 12 | pattern: String 13 | ) on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION | FIELD_DEFINITION 14 | 15 | type Mutation { 16 | sendMessage(text: String @constraint(pattern: "^[A-Za-z0-9 ]$")): String 17 | } 18 | ` 19 | 20 | const resolvers = { 21 | Mutation: { 22 | sendMessage: async (_, obj) => { 23 | const { text } = obj 24 | return text 25 | } 26 | } 27 | } 28 | 29 | t.test('registrations', t => { 30 | t.plan(7) 31 | 32 | t.test('registration - should error if mercurius is not loaded', async (t) => { 33 | t.plan(1) 34 | 35 | const app = Fastify() 36 | t.teardown(app.close.bind(app)) 37 | 38 | t.rejects(app.register(mercuriusValidation, {}), new AssertionError({ 39 | message: 40 | "The dependency 'mercurius' of plugin 'mercurius-validation' is not registered", 41 | actual: false, 42 | expected: true, 43 | operator: '==' 44 | })) 45 | }) 46 | 47 | t.test('registration - should error if schema is defined but not an object', async (t) => { 48 | t.plan(1) 49 | 50 | const app = Fastify() 51 | t.teardown(app.close.bind(app)) 52 | 53 | app.register(mercurius, { 54 | schema, 55 | resolvers 56 | }) 57 | 58 | t.rejects( 59 | app.register(mercuriusValidation, { schema: 'string' }), 60 | new MER_VALIDATION_ERR_INVALID_OPTS('opts.schema must be an object.') 61 | ) 62 | }) 63 | 64 | t.test('registration - should error if mode is defined but not a string', async (t) => { 65 | t.plan(1) 66 | 67 | const app = Fastify() 68 | t.teardown(app.close.bind(app)) 69 | 70 | app.register(mercurius, { 71 | schema, 72 | resolvers 73 | }) 74 | 75 | t.rejects( 76 | app.register(mercuriusValidation, { mode: 123456 }), 77 | new MER_VALIDATION_ERR_INVALID_OPTS('opts.mode must be a string.') 78 | ) 79 | }) 80 | 81 | t.test('registration - should error if directiveValidation is defined but not a boolean', async (t) => { 82 | t.plan(1) 83 | 84 | const app = Fastify() 85 | t.teardown(app.close.bind(app)) 86 | 87 | app.register(mercurius, { 88 | schema, 89 | resolvers 90 | }) 91 | 92 | t.rejects( 93 | app.register(mercuriusValidation, { directiveValidation: 'string' }), 94 | new MER_VALIDATION_ERR_INVALID_OPTS('opts.directiveValidation must be a boolean.') 95 | ) 96 | }) 97 | 98 | t.test('registration - should error if schema type is not an object', async (t) => { 99 | t.plan(1) 100 | 101 | const app = Fastify() 102 | t.teardown(app.close.bind(app)) 103 | 104 | app.register(mercurius, { 105 | schema, 106 | resolvers 107 | }) 108 | 109 | t.rejects( 110 | app.register(mercuriusValidation, { schema: { foo: 'bad' } }), 111 | new MER_VALIDATION_ERR_INVALID_OPTS('opts.schema.foo must be an object.') 112 | ) 113 | }) 114 | 115 | t.test('registration - should error if schema type field is a function', async (t) => { 116 | t.plan(1) 117 | 118 | const app = Fastify() 119 | t.teardown(app.close.bind(app)) 120 | 121 | app.register(mercurius, { 122 | schema, 123 | resolvers 124 | }) 125 | 126 | t.rejects( 127 | app.register(mercuriusValidation, { schema: { foo: { bar: () => {} } } }), 128 | new MER_VALIDATION_ERR_INVALID_OPTS('opts.schema.foo.bar cannot be a function. Only field arguments currently support functional validators.') 129 | ) 130 | }) 131 | 132 | t.test('registration - should register the plugin without options', async (t) => { 133 | t.plan(1) 134 | 135 | const app = Fastify() 136 | t.teardown(app.close.bind(app)) 137 | 138 | app.register(mercurius, { 139 | schema, 140 | resolvers 141 | }) 142 | await app.register(mercuriusValidation) 143 | 144 | t.ok('mercurius validation plugin is registered without options') 145 | }) 146 | }) 147 | -------------------------------------------------------------------------------- /test/types/index.test-d.ts: -------------------------------------------------------------------------------- 1 | import { expectType } from 'tsd' 2 | import fastify from 'fastify' 3 | import { GraphQLDirective, GraphQLResolveInfo } from 'graphql' 4 | import { MercuriusContext } from 'mercurius' 5 | import mercuriusValidation, { MercuriusValidationHandler, MercuriusValidationHandlerMetadata, MercuriusValidationOptions } from '../..' 6 | 7 | // Validate GraphQL definitions 8 | expectType(mercuriusValidation.graphQLTypeDefs) 9 | expectType(mercuriusValidation.graphQLDirective) 10 | 11 | const app = fastify() 12 | 13 | // Register without options 14 | app.register(mercuriusValidation) 15 | app.register(mercuriusValidation, {}) 16 | 17 | // Register with AJV options 18 | app.register(mercuriusValidation, { 19 | coerceTypes: false 20 | }) 21 | 22 | // Use different modes 23 | app.register(mercuriusValidation, { mode: 'JTD' }) 24 | app.register(mercuriusValidation, { mode: 'JSONSchema' }) 25 | 26 | // Turn directive validation on/off 27 | app.register(mercuriusValidation, { directiveValidation: true }) 28 | app.register(mercuriusValidation, { directiveValidation: false }) 29 | 30 | // Register JSON Schema definitions 31 | app.register(mercuriusValidation, { 32 | mode: 'JSONSchema', 33 | schema: { 34 | Filters: { 35 | text: { minLength: 1 } 36 | }, 37 | Query: { 38 | message: { 39 | id: { 40 | minLength: 1 41 | } 42 | } 43 | } 44 | } 45 | }) 46 | 47 | // Register JTD definitions 48 | app.register(mercuriusValidation, { 49 | mode: 'JTD', 50 | schema: { 51 | Filters: { 52 | text: { enum: ['hello', 'there'] } 53 | }, 54 | Query: { 55 | message: { 56 | id: { 57 | type: 'uint8' 58 | } 59 | } 60 | } 61 | } 62 | }) 63 | 64 | // Register Function definitions 65 | app.register(mercuriusValidation, { 66 | schema: { 67 | Query: { 68 | message: { 69 | async id (metadata, value, parent, args, context, info) { 70 | expectType(metadata) 71 | expectType(value) 72 | expectType(parent) 73 | expectType(args) 74 | expectType(context) 75 | expectType(info) 76 | } 77 | } 78 | } 79 | } 80 | }) 81 | 82 | // Using options as object without generics 83 | interface CustomParent { 84 | parent: Record; 85 | } 86 | interface CustomArgs { 87 | arg: Record; 88 | } 89 | interface CustomContext extends MercuriusContext { 90 | hello?: string; 91 | } 92 | const validationOptions: MercuriusValidationOptions = { 93 | schema: { 94 | Query: { 95 | message: { 96 | async id ( 97 | metadata, 98 | value, 99 | parent: CustomParent, 100 | args: CustomArgs, 101 | context: CustomContext, 102 | info 103 | ) { 104 | expectType(metadata) 105 | expectType(value) 106 | expectType(parent) 107 | expectType(args) 108 | expectType(context) 109 | expectType(context?.hello) 110 | expectType(info) 111 | } 112 | } 113 | } 114 | } 115 | } 116 | app.register(mercuriusValidation, validationOptions) 117 | 118 | // Using options as input object with generics 119 | const authOptionsWithGenerics: MercuriusValidationOptions = { 120 | schema: { 121 | Query: { 122 | message: { 123 | async id (metadata, value, parent, args, context, info) { 124 | expectType(metadata) 125 | expectType(value) 126 | expectType(parent) 127 | expectType(args) 128 | expectType(context) 129 | expectType(context?.hello) 130 | expectType(info) 131 | } 132 | } 133 | } 134 | } 135 | } 136 | app.register(mercuriusValidation, authOptionsWithGenerics) 137 | 138 | // Creating functions using handler types 139 | const id: MercuriusValidationHandler<{}, {}, CustomContext> = 140 | async (metadata, value, parent, args, context, info) => { 141 | expectType(metadata) 142 | expectType(value) 143 | expectType<{}>(parent) 144 | expectType<{}>(args) 145 | expectType(context) 146 | expectType(info) 147 | expectType(context?.hello) 148 | } 149 | app.register(mercuriusValidation, { 150 | schema: { 151 | Query: { 152 | message: { 153 | id 154 | } 155 | } 156 | } 157 | }) 158 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2018", 4 | "module": "commonjs", 5 | "noEmit": true, 6 | "strict": true, 7 | "esModuleInterop": true 8 | }, 9 | "exclude": ["node_modules"], 10 | "files": ["./test/types/index.test-d.ts"] 11 | } 12 | --------------------------------------------------------------------------------