diff --git a/index.js b/index.js index 78a21bd..6f57fe6 100644 --- a/index.js +++ b/index.js @@ -12,7 +12,9 @@ const { assign, flatten, isEmpty, - mapValues + mapValues, + omitBy, + isNull } = require('lodash') const { GraphQLSchema, @@ -27,6 +29,13 @@ const { GraphQLNonNull } = require('graphql') +// Convenience helpers to determine a Joi schema's +// "presence", e.g. required or forbidden +const presence = (desc, name) => + desc.flags && + desc.flags.presence && + desc.flags.presence === name + // Cache converted types by their `meta({ name: '' })` property so we // don't end up with a litter of anonymously generated GraphQL types const cachedTypes = {} @@ -39,12 +48,7 @@ const descToType = (desc, isInput) => { (isInput ? 'Input' : '') + (map(desc.meta, 'name')[0] || 'Anon' + uniqueId()) ) - const required = ( - isInput && - desc.flags && - desc.flags.presence && - desc.flags.presence === 'required' - ) + const required = isInput && presence(desc, 'required') const type = { boolean: () => GraphQLBoolean, date: () => GraphQLString, @@ -60,8 +64,10 @@ const descToType = (desc, isInput) => { type = new GraphQLInputObjectType({ name: typeName, description: desc.description, - fields: mapValues(desc.children, (child) => ( - { type: descToType(child, true) })) + fields: omitBy(mapValues(desc.children, (child) => { + if (presence(child, 'forbidden')) return null + return { type: descToType(child, true) } + }), isNull) }) } else { type = new GraphQLObjectType({ @@ -75,10 +81,11 @@ const descToType = (desc, isInput) => { }, array: () => { let type - if (desc.items.length === 1) { - type = descToType(desc.items[0], isInput) + const items = desc.items.filter((item) => !presence(item, 'forbidden')) + if (items.length === 1) { + type = descToType(items[0], isInput) } else { - typeName = map(desc.items, (d) => { + typeName = map(items, (d) => { const name = ( (d.meta && capitalize(d.meta.name)) || capitalize(d.type) || @@ -89,9 +96,9 @@ const descToType = (desc, isInput) => { if (cachedTypes[typeName]) { type = cachedTypes[typeName] } else { - const types = desc.items.map((item) => descToType(item, isInput)) + const types = items.map((item) => descToType(item, isInput)) if (isInput) { - const children = desc.items.map((item) => item.children) + const children = items.map((item) => item.children) const fields = descsToFields(assign(...flatten(children))) type = new GraphQLInputObjectType({ name: typeName, @@ -106,7 +113,7 @@ const descToType = (desc, isInput) => { // TODO: Should use JOI.validate(), just looks at matching keys // We might need to pass schema here instead resolveType: (val) => - find(map(desc.items, (item, i) => + find(map(items, (item, i) => isEqual(keys(val), keys(item.children)) && types[i])) }) } @@ -117,10 +124,12 @@ const descToType = (desc, isInput) => { }, alternatives: () => { let type + const alternatives = desc.alternatives + .filter((a) => !presence(a, 'forbidden')) if (cachedTypes[typeName]) return cachedTypes[typeName] - const types = desc.alternatives.map((item) => + const types = alternatives.map((item) => descToType(item, isInput)) - const children = desc.alternatives.map((item) => item.children) + const children = alternatives.map((item) => item.children) const fields = descsToFields(assign(...flatten(children))) if (isInput) { type = new GraphQLInputObjectType({ @@ -134,7 +143,7 @@ const descToType = (desc, isInput) => { description: desc.description, types: types, resolveType: (val) => - find(map(desc.alternatives, (item, i) => { + find(map(alternatives, (item, i) => { const isTypeOf = map(item.meta, 'isTypeOf')[0] if (isTypeOf) return isTypeOf(val) && types[i] // TODO: Should use JOI.validate(), just looks at matching keys @@ -154,9 +163,12 @@ const descToType = (desc, isInput) => { // arguments const descToArgs = (desc) => { const argsSchema = map(desc.meta, 'args')[0] - return argsSchema && mapValues(argsSchema, (schema) => ({ - type: descToType(schema.describe(), true) - })) + return argsSchema && omitBy(mapValues(argsSchema, (schema) => { + if (presence(schema.describe(), 'forbidden')) return null + return { + type: descToType(schema.describe(), true) + } + }), isNull) } // Wraps a resolve function specifid in a Joi schema to add validation. @@ -175,12 +187,15 @@ const validatedResolve = (desc) => (source, args, root, opts) => { // Convert a hash of descriptions into an object appropriate to put in a // GraphQL.js `fields` key. const descsToFields = (descs, resolveMiddlewares = () => {}) => - mapValues(descs, (desc) => ({ - type: descToType(desc), - args: descToArgs(desc), - description: desc.description || '', - resolve: validatedResolve(desc) - })) + omitBy(mapValues(descs, (desc) => { + if (presence(desc, 'forbidden')) return null + return { + type: descToType(desc), + args: descToArgs(desc), + description: desc.description || '', + resolve: validatedResolve(desc) + } + }), isNull) // Converts the { key: JoiSchema } pairs to a GraphQL.js schema object module.exports = (jois) => { diff --git a/package.json b/package.json index f354761..86f2b49 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "joiql", - "version": "0.1.2", + "version": "0.1.3", "description": "Make GraphQL schema creation and data validation easy with Joi.", "main": "index.js", "scripts": { diff --git a/test/integration.js b/test/integration.js index 814d41e..7924774 100644 --- a/test/integration.js +++ b/test/integration.js @@ -1,5 +1,5 @@ /* eslint-env mocha */ -const { string, number, object, date } = require('joi') +const { string, number, object, date, array, alternatives } = require('joi') const { graphql } = require('graphql') const joiql = require('../') @@ -69,4 +69,94 @@ describe('joiql', () => { ;(typeof undefined).should.equal('undefined') }) }) + + it('omits forbidden fields', () => { + const schema = joiql({ + query: { + a: string().forbidden(), + b: string(), + c: object({ + d: string().forbidden(), + e: string() + }), + f: array().items(object({ + g: string().forbidden(), + h: string() + })), + j: array().items( + object({ k: string() }), + object({ l: string() }).forbidden() + ), + m: alternatives( + object({ n: string() }).meta({ name: 'N' }), + object({ o: string() }).meta({ name: 'O' }).forbidden() + ) + } + }) + return graphql(schema, '{ a c { d } f { g } j { k l } m { o } }') + .then((res) => { + const errs = res.errors.map((e) => e.message).join('') + errs.should.containEql('Cannot query field "a"') + errs.should.containEql('Cannot query field "d"') + errs.should.containEql('Cannot query field "g"') + errs.should.containEql('Cannot query field "l"') + errs.should.containEql('Cannot query field "o"') + }) + .then(() => + graphql(schema, '{ a b c { e } f { h } j { k } m ... on N { n } }') + ) + .then((res) => { + const errs = res.errors.map((e) => e.message).join('') + errs.should.not.containEql('Cannot query field "b"') + errs.should.not.containEql('Cannot query field "e"') + errs.should.not.containEql('Cannot query field "h"') + errs.should.not.containEql('Cannot query field "k"') + errs.should.not.containEql('Cannot query field "n"') + }) + }) + + it('omits forbidden args', () => { + const schema = joiql({ + query: { + a: string().meta({ + args: { + b: string().forbidden(), + c: string(), + d: object({ + e: string().forbidden(), + f: string() + }), + g: array().items(object({ + h: string().forbidden(), + i: string() + })), + j: array().items( + object({ k: string() }), + object({ l: string() }).forbidden() + ), + m: alternatives( + object({ n: string() }).meta({ name: 'N' }), + object({ o: string() }).meta({ name: 'O' }).forbidden() + ) + } + }) + } + }) + return graphql(schema, `{ + a( + b: "Foo" + d: { e: "Foo" } + g: { h: "Foo" } + j: { l: "Foo" } + m: { o: "Foo" } + ) + }`).then((res) => { + const errs = res.errors.map((e) => e.message).join('') + errs.should.containEql('Unknown argument "b"') + errs.should.containEql('has invalid value {e') + errs.should.containEql('has invalid value {h') + errs.should.containEql('has invalid value {l') + errs.should.containEql('has invalid value {o') + }) + }) })