@ephys/graphql-joi-directives
Version:
Adds Joi-powered constraint directive for GraphQL
206 lines (205 loc) • 8.91 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.buildJoiListDirectiveTypedef = exports.buildJoiListDirective = void 0;
const graphql_1 = require("graphql");
const graphql_tools_1 = require("graphql-tools");
const joi_1 = __importDefault(require("joi"));
const get_1 = __importDefault(require("lodash/get"));
function buildJoiListDirective(directiveName) {
class JoiConstraintListDirective extends graphql_tools_1.SchemaDirectiveVisitor {
#processedSchemas = new WeakSet();
// input MyInput {
// varOne: [String!]! @list(min: 1)
// }
visitInputFieldDefinition(field) {
this.#assertIsListType(field.type);
// we need to process the whole schema to find which resolver accept this Input
this.#processSchema();
}
// type Query {
// myQuery(var: [String!]! @list(min: 1))
// }
visitArgumentDefinition(arg, details) {
const { field } = details;
const { resolve = graphql_1.defaultFieldResolver } = field;
const directiveArguments = this.args;
const resolverArgumentName = arg.name;
const joiSchema = this.#getSchema(directiveArguments, resolverArgumentName);
// TODO:
// only set field.resolve the first time & put argName + schema in an internal map
// same for processSchema().
// this way we only ever have one .resolve wrapper (instead of one per @list)
// --
// then do the same thing for @nonNull
field.resolve = function resolveWrapper(...resolverArgs) {
const fieldArgs = resolverArgs[1];
const theList = fieldArgs[arg.name];
if (theList != null) {
runJoi(joiSchema, theList);
}
return resolve.apply(this, resolverArgs);
};
return arg;
}
#processSchema() {
if (this.#processedSchemas.has(this.schema)) {
return;
}
const typeMap = this.schema.getTypeMap(); // checking `input {}` definition
for (const type of Object.values(typeMap)) {
if (!graphql_1.isObjectType(type)) {
continue;
}
for (const field of Object.values(type.getFields())) {
const taggedInputs = [];
for (const arg of field.args) {
let argType = arg.type;
// Unpack the "!" operator
// NB. we're not unpacking lists because @nonNull does not make sense inside the list, use "!" for that.
// so @nonNull only tags the list itself
if (graphql_1.isNonNullType(argType)) {
argType = argType.ofType;
}
if (graphql_1.isInputObjectType(argType)) {
const foundInputFields = findTaggedInputFields(argType, directiveName, [arg.name]);
taggedInputs.push(...foundInputFields);
}
}
if (taggedInputs.length > 0) {
taggedInputs.sort((a, b) => a.path.length - b.path.length);
const { resolve = graphql_1.defaultFieldResolver } = field;
const schemas = new WeakMap();
for (const taggedInput of taggedInputs) {
const joiSchema = this.#getSchema(taggedInput.directiveArgs, taggedInput.path.join('.'));
schemas.set(taggedInput.path, joiSchema);
}
// eslint-disable-next-line @typescript-eslint/no-loop-func
field.resolve = function resolveWrapper(...resolverArgs) {
const fieldArgs = resolverArgs[1];
for (const taggedInput of taggedInputs) {
const theList = get_1.default(fieldArgs, taggedInput.path);
// nullability is handled by ! or @nonNull
if (theList == null) {
continue;
}
const joiSchema = schemas.get(taggedInput.path);
runJoi(joiSchema, theList);
}
return resolve.apply(this, resolverArgs);
};
}
}
}
this.#processedSchemas.add(this.schema);
}
#getSchema(args, propertyName) {
let joiSchema = joi_1.default.array().label(propertyName);
for (const argKey of Object.keys(args)) {
const argVal = args[argKey];
switch (argKey) {
case 'min':
joiSchema = joiSchema.min(argVal);
break;
case 'max':
joiSchema = joiSchema.max(argVal);
break;
default:
throw new Error(`Unsupported argument ${argKey}.`);
}
}
return joiSchema;
}
#assertIsListType(type) {
if (graphql_1.isNonNullType(type)) {
type = type.ofType;
}
if (!graphql_1.isListType(type)) {
throw new Error(`@${directiveName} can only be used on type GraphQLList. It was used on ${type.name}`);
}
}
}
return JoiConstraintListDirective;
}
exports.buildJoiListDirective = buildJoiListDirective;
function runJoi(joiSchema, value) {
const output = joiSchema.validate(value, { convert: false });
if (output.error) {
throw new graphql_1.GraphQLError(output.error.message);
}
}
function findTaggedInputFields(inputType, directiveName, path = []) {
const matchedFields = [];
for (const field of Object.values(inputType.getFields())) {
// find nested objects
// unpack "!" operator
let fieldType = field.type;
if (fieldType instanceof graphql_1.GraphQLNonNull) {
fieldType = fieldType.ofType;
}
if (fieldType instanceof graphql_1.GraphQLInputObjectType) {
// sub-input objects
findTaggedInputFields(fieldType, directiveName, [...path, field.name]);
} // find @nonNull directives
// this is the tagged input property!
const foundDirective = field.astNode.directives.find(d => d.name.value === directiveName);
if (foundDirective != null) {
matchedFields.push({
path: [...path, field.name],
directiveArgs: getDirectiveArgs(foundDirective),
});
}
}
return matchedFields;
}
function getDirectiveArgs(directive) {
const out = Object.create(null);
if (!directive.arguments) {
return out;
}
for (const { name: nameNode, value } of directive.arguments) {
const name = nameNode.value;
out[name] = getValueNodeValue(value, directive.name.value, [name]);
}
return out;
}
function getValueNodeValue(node, directiveName, argumentPath) {
if (node.kind === 'Variable') {
throw new Error(`Directive @${directiveName} does not accept Variable arguments (argument ${argumentPath.join(' -> ')})`);
}
if (node.kind === 'NullValue') {
return null;
}
if (node.kind === 'ListValue') {
return node.values.map(valueNode => getValueNodeValue(valueNode, directiveName, argumentPath));
}
if (node.kind === 'ObjectValue') {
const out = Object.create(null);
for (const field of node.fields) {
const fieldName = field.name.value;
out[fieldName] = getValueNodeValue(field.value, directiveName, [...argumentPath, fieldName]);
}
return out;
}
if (node.kind === 'IntValue' || node.kind === 'FloatValue') {
return Number(node.value);
}
return node.value;
}
function buildJoiListDirectiveTypedef(directiveName) {
return `
directive @${directiveName}(
# requires a minimum of {min} items in the list
min: Int,
# requires a maximum of {max} items in the list
max: Int,
# requires exactly {length} items in the list
length: Int,
# removes duplicate items from the list
unique: Boolean,
) on INPUT_FIELD_DEFINITION | ARGUMENT_DEFINITION
`;
}
exports.buildJoiListDirectiveTypedef = buildJoiListDirectiveTypedef;