diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ca10e78..23b39a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,12 +7,12 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - node-version: [14.x, 16.x, 18.x] + node-version: [18.x, 20.x] os: [ubuntu-latest, windows-latest, macOS-latest] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} - name: Install Dependencies diff --git a/docs/api/options.md b/docs/api/options.md index 9631c68..6760909 100644 --- a/docs/api/options.md +++ b/docs/api/options.md @@ -11,6 +11,7 @@ Extends: [`AJVOptions`](https://ajv.js.org/options.html) * **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. * **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. * **directiveValidation** `boolean` (optional, default: `true`) - turn directive validation on or off. It is on by default. +* **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. 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. diff --git a/docs/json-schema-validation.md b/docs/json-schema-validation.md index 00e58cf..2a2975e 100644 --- a/docs/json-schema-validation.md +++ b/docs/json-schema-validation.md @@ -478,6 +478,28 @@ app.register(mercuriusValidation, { }) ``` +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' }`. + +```js +app.register(mercuriusValidation, { + schema: { + Filters: { + isAvailable: { type: 'boolean' } + }, + Query: { + product: { + id: { type: 'string', minLength: 1 } + } + } + }, + customTypeInferenceFn: (type, isNonNull) => { + if (type === GraphQLBoolean) { + return isNonNull ? { type: 'boolean' } : { type: ['boolean', 'null'] } + } + } +}) +``` + ## Caveats 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. diff --git a/lib/utils.js b/lib/utils.js index d7f4a21..9255ffd 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -35,7 +35,13 @@ function validateOpts (opts) { return opts } -function inferJSONSchemaType (type, isNonNull) { +function inferJSONSchemaType (type, isNonNull, customTypeInferenceFn) { + if (customTypeInferenceFn) { + const customResponse = customTypeInferenceFn(type, isNonNull) + if (customResponse) { + return customResponse + } + } if (type === GraphQLString) { return isNonNull ? { type: 'string' } : { type: ['string', 'null'] } } diff --git a/lib/validators/json-schema-validator.js b/lib/validators/json-schema-validator.js index fd6544e..351ca53 100644 --- a/lib/validators/json-schema-validator.js +++ b/lib/validators/json-schema-validator.js @@ -12,7 +12,7 @@ const { getTypeInfo, inferJSONSchemaType } = require('../utils') class JSONSchemaValidator extends Validator { [kValidationSchema] (type, namedType, isNonNull, typeValidation, id) { let builtValidationSchema = { - ...inferJSONSchemaType(namedType, isNonNull), + ...inferJSONSchemaType(namedType, isNonNull, this[kOpts].customTypeInferenceFn), $id: id } @@ -31,7 +31,7 @@ class JSONSchemaValidator extends Validator { } // If we have an array of scalars, set the array type and infer the items } else if (isListType(type)) { - let items = { ...inferJSONSchemaType(namedType, isNonNull), ...builtValidationSchema.items } + let items = { ...inferJSONSchemaType(namedType, isNonNull, this[kOpts].customTypeInferenceFn), ...builtValidationSchema.items } if (typeValidation !== null) { items = { ...items, ...typeValidation.items } } diff --git a/package.json b/package.json index d41d0f2..523c84d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mercurius-validation", - "version": "4.0.0", + "version": "5.0.0", "description": "Mercurius Validation Plugin adds configurable Validation support to Mercurius.", "main": "index.js", "types": "index.d.ts", @@ -34,31 +34,31 @@ }, "homepage": "https://github.com/mercurius-js/validation", "devDependencies": { - "@mercuriusjs/federation": "^2.0.0", - "@mercuriusjs/gateway": "^1.0.0", - "@sinonjs/fake-timers": "^10.0.2", - "@types/node": "^20.1.0", - "@types/ws": "^8.5.3", + "@mercuriusjs/federation": "^3.0.0", + "@mercuriusjs/gateway": "^3.0.0", + "@sinonjs/fake-timers": "^11.2.2", + "@types/node": "^22.0.0", + "@types/ws": "^8.5.10", "@typescript-eslint/eslint-plugin": "^5.30.3", "@typescript-eslint/parser": "^5.30.3", - "autocannon": "^7.9.0", - "concurrently": "^8.0.1", - "fastify": "^4.2.0", - "mercurius": "^13.0.0", + "autocannon": "^7.15.0", + "concurrently": "^9.0.0", + "fastify": "^4.26.2", + "mercurius": "^14.0.0", "pre-commit": "^1.2.2", "snazzy": "^9.0.0", - "standard": "^17.0.0", + "standard": "^17.1.0", "tap": "^16.3.0", - "tsd": "^0.28.0", - "typescript": "^5.0.2", - "wait-on": "^7.0.1" + "tsd": "^0.31.0", + "typescript": "^5.4.2", + "wait-on": "^8.0.0" }, "dependencies": { - "@fastify/error": "^3.0.0", + "@fastify/error": "^4.0.0", "ajv": "^8.6.2", "ajv-errors": "^3.0.0", - "ajv-formats": "^2.1.1", - "fastify-plugin": "^4.0.0", + "ajv-formats": "^3.0.1", + "fastify-plugin": "^5.0.1", "graphql": "^16.2.0" }, "tsd": { diff --git a/test/json-schema-validation.js b/test/json-schema-validation.js index 09df4d1..3a00f1c 100644 --- a/test/json-schema-validation.js +++ b/test/json-schema-validation.js @@ -5,6 +5,7 @@ const Fastify = require('fastify') const mercurius = require('mercurius') const mercuriusValidation = require('..') const { MER_VALIDATION_ERR_FIELD_TYPE_UNDEFINED } = require('../lib/errors') +const { GraphQLBoolean } = require('graphql') const schema = ` type Message { @@ -69,7 +70,7 @@ const resolvers = { } t.test('JSON Schema validators', t => { - t.plan(18) + t.plan(19) t.test('should protect the schema and not affect operations when everything is okay', async (t) => { t.plan(1) @@ -2126,4 +2127,124 @@ t.test('JSON Schema validators', t => { } }) }) + + t.test('should invoke customTypeInferenceFn option and not affect operations when everything is okay', async (t) => { + const productSchema = ` + type Product { + id: ID! + text: String + isAvailable: Boolean + } + + input Filters { + id: ID + text: String + isAvailable: Boolean + } + + type Query { + noResolver(id: ID): ID + product(id: ID): Product + products( + filters: Filters + ): [Product] + } + ` + + const products = [ + { + id: 0, + text: 'Phone', + isAvailable: true + }, + { + id: 1, + text: 'Laptop', + isAvailable: true + }, + { + id: 2, + text: 'Keyboard', + isAvailable: false + } + ] + + const productResolvers = { + Query: { + product: async (_, { id }) => { + return products.find(product => product.id === Number(id)) + }, + products: async (_, { filters }) => { + return products.filter(product => product.isAvailable === filters.isAvailable) + } + } + } + + const app = Fastify() + t.teardown(app.close.bind(app)) + + app.register(mercurius, { + schema: productSchema, + resolvers: productResolvers + }) + app.register(mercuriusValidation, { + schema: { + Filters: { + isAvailable: { type: 'boolean' } + }, + Query: { + product: { + id: { type: 'string', minLength: 1 } + } + } + }, + customTypeInferenceFn: (type, isNonNull) => { + if (type === GraphQLBoolean) { + return isNonNull ? { type: 'boolean' } : { type: ['boolean', 'null'] } + } + } + }) + + const query = `query { + product(id: "1") { + id + text + isAvailable + } + products(filters: { isAvailable: true }) { + id + text + isAvailable + } + }` + + const response = await app.inject({ + method: 'POST', + headers: { 'content-type': 'application/json' }, + url: '/graphql', + body: JSON.stringify({ query }) + }) + + t.same(JSON.parse(response.body), { + data: { + product: { + id: 1, + text: 'Laptop', + isAvailable: true + }, + products: [ + { + id: 0, + text: 'Phone', + isAvailable: true + }, + { + id: 1, + text: 'Laptop', + isAvailable: true + } + ] + } + }) + }) })