UNPKG

@aws/neptune-for-graphql

Version:

CLI utility to create and maintain a GraphQL API for Amazon Neptune

446 lines (375 loc) 15.8 kB
/* Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance with the License. A copy of the License is located at http://www.apache.org/licenses/LICENSE-2.0 or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ import { schemaStringify } from './schemaParser.js'; import { GraphQLID, print } from 'graphql'; import {gql} from 'graphql-tag' import { loggerInfo, yellow } from "./logger.js"; // TODO change variables to local scope instead of global so this module can be used against multiple schemas const typesToAdd = []; const queriesToAdd = []; const mutationsToAdd = []; const enumTypes = []; const customScalarTypes = ['AWSDate', 'AWSTime', 'AWSDateTime', 'AWSTimestamp', 'AWSEmail', 'AWSJSON', 'AWSPhone', 'AWSURL', 'AWSIPAddress']; function lowercaseFirstCharacter(inputString) { if (inputString.length === 0) { return inputString; } const firstChar = inputString.charAt(0); const restOfString = inputString.slice(1); const lowercasedFirstChar = firstChar.toLowerCase(); return lowercasedFirstChar + restOfString; } function isGraphDBDirectives(schemaModel) { let r = false; schemaModel.definitions.forEach(def => { if (def.kind == 'ObjectTypeDefinition') { def.fields.forEach(field => { if (field.directives) { field.directives.forEach(directive => { if (directive.name.value == 'cypher') { r = true; } if (directive.name.value == 'graphQuery') { r = true; } if (directive.name.value == 'relationship') { r = true; } }); } }); } }); return r; } function addRelationshipDirective(field, type, direction) { field.directives.push({ kind: 'Directive', name: { kind: 'Name', value: 'relationship' }, arguments: [ { kind: 'Argument', name: { kind: 'Name', value: 'type' }, value: { kind: 'StringValue', value: type, block: false } }, { kind: 'Argument', name: { kind: 'Name', value: 'direction' }, value: { kind: 'EnumValue', value: direction } } ] }); } function injectChanges(schemaModel) { let r = ''; let stringModel = schemaStringify(schemaModel, true); stringModel += '\n'; typesToAdd.forEach(type => { stringModel += '\n' + type + '\n'; }); if (!stringModel.includes('type Query {')) stringModel += '\ntype Query {\n}\n'; if (!stringModel.includes('type Mutation {')) stringModel += '\ntype Mutation {\n}\n'; if (!stringModel.includes('schema {')) stringModel += '\nschema {\n query: Query\n mutation: Mutation\n}\n'; const lines = stringModel.split('\n'); lines.forEach(line => { r += line + '\n'; if (line.includes('type Query {')) { queriesToAdd.forEach(query => { r += " " + query; }); } if (line.includes('type Mutation {')) { mutationsToAdd.forEach(mutation => { r += " " + mutation; }); } }); return gql(r); } function addNode(def, { queryPrefix = '', mutationPrefix = ''} = {}) { let name = def.name.value; const idField = getIdFieldWithDirective(def); // General Input type const scalarFields = getInputFields(def); typesToAdd.push(`input ${name}Input {\n${print(scalarFields)}\n}`); // Create Input type const createFields = []; for (const field of def.fields) { if (isScalarOrEnum(nullable(field.type))) { if (field.type?.kind === 'NonNullType' && field.type?.type?.name?.value === GraphQLID.name) { const idFieldCopy = { ...field, type: field.type.type }; createFields.push(idFieldCopy); } else { createFields.push(field); } } } typesToAdd.push(`input ${name}CreateInput {\n${print(createFields)}\n}`); // Update Input type const updateFields = []; for (const field of def.fields) { if (isScalarOrEnum(nullable(field.type))) { if (field.type?.kind === 'NonNullType' && field.type?.type?.name?.value !== GraphQLID.name) { const fieldCopy = { ...field, type: field.type.type }; updateFields.push(fieldCopy); } else { updateFields.push(field); } } } typesToAdd.push(`input ${name}UpdateInput {\n${print(updateFields)}\n}`); // Sort Input type const sortFields = scalarFields.map(scalarField => ({ ...scalarField, type: { kind: 'NamedType', name: {kind: 'Name', value: 'SortDirection'} }, directives: [] })); typesToAdd.push(`input ${name}Sort {\n${print(sortFields)}\n}`); // Create query const getSingleQueryName = `${queryPrefix}get${name}`; queriesToAdd.push(`${getSingleQueryName}(filter: ${name}Input): ${name}\n`); const getMultipleQueryName = `${queryPrefix}get${name}s`; queriesToAdd.push(`${getMultipleQueryName}(filter: ${name}Input, options: Options, sort: [${name}Sort!]): [${name}]\n`); // Create mutation const createMutationName = `${mutationPrefix}create${name}`; mutationsToAdd.push(`${createMutationName}(input: ${name}CreateInput!): ${name}\n`); const updateMutationName = `${mutationPrefix}update${name}`; mutationsToAdd.push(`${updateMutationName}(input: ${name}UpdateInput!): ${name}\n`); const deleteMutationName = `${mutationPrefix}delete${name}`; mutationsToAdd.push(`${deleteMutationName}(${print(idFieldToInputValue(idField))}): Boolean\n`); loggerInfo(`Added input type: ${yellow(name+'Input')}`); loggerInfo(`Added input type: ${yellow(name+'CreateInput')}`); loggerInfo(`Added input type: ${yellow(name+'UpdateInput')}`); loggerInfo(`Added query: ${yellow(getSingleQueryName)}`); loggerInfo(`Added query: ${yellow(getMultipleQueryName)}`); loggerInfo(`Added mutation: ${yellow(createMutationName)}`); loggerInfo(`Added mutation: ${yellow(updateMutationName)}`); loggerInfo(`Added mutation: ${yellow(deleteMutationName)}`); } function addEdge(from, to, edgeName, { mutationPrefix = ''} = {}) { if (!typesToAdd.some((str) => str.startsWith(`type ${edgeName}`))) { // Create type typesToAdd.push(`type ${edgeName} {\n _id: ID! @id\n}`); // Create mutation mutationsToAdd.push(`${mutationPrefix}connect${from}To${to}Through${edgeName}(from_id: ID!, to_id: ID!): ${edgeName}\n`); mutationsToAdd.push(`${mutationPrefix}delete${edgeName}ConnectionFrom${from}To${to}(from_id: ID!, to_id: ID!): Boolean\n`); loggerInfo(`Added type for edge: ${yellow(edgeName)}`); loggerInfo(`Added mutation: ${yellow(`connect${from}To${to}Through${edgeName}`)}`); loggerInfo(`Added mutation: ${yellow(`delete${edgeName}ConnectionFrom${from}To${to}`)}`); } } function addFilterOptionsSortArguments(field) { // filter field.arguments.push({ kind: 'InputValueDefinition', name: { kind: 'Name', value: 'filter' }, type: { kind: 'NamedType', name: { kind: 'Name', value: field.type.type.name.value + 'Input' } } }); // options field.arguments.push({ kind: 'InputValueDefinition', name: { kind: 'Name', value: 'options' }, type: { kind: 'NamedType', name: { kind: 'Name', value: 'Options' } } }); // sort field.arguments.push({ kind: 'InputValueDefinition', name: { kind: 'Name', value: 'sort' }, type: { kind: 'NamedType', name: { kind: 'Name', value: field.type.type.name.value + 'Sort' } } }); } function getIdFieldWithDirective(objTypeDef) { return objTypeDef.fields.find( field => field.directives && field.directives.some(directive => directive.name.value === 'id') ); } function getIdFieldByType(objTypeDef) { return objTypeDef.fields.find(field => field.type?.type?.kind === 'NamedType' && field.type?.type?.name?.value === GraphQLID.name); } function createIdDirective() { return {kind: 'Directive', name: {kind: 'Name', value: 'id'}, arguments: []}; } function createIdFieldWithDirective() { return { kind: 'FieldDefinition', name: { kind: 'Name', value: '_id' }, arguments: [], type: { kind: 'NonNullType', type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } } }, directives: [createIdDirective()] }; } function idFieldToInputValue({ name, type }) { return { kind: 'InputValueDefinition', name, type }; } function getInputFields(objTypeDef) { return objTypeDef.fields.filter(field => isScalarOrEnum(nullable(field.type))); } function nullable(type) { return type.kind === 'NonNullType' ? type.type : type; } function isScalarOrEnum(type) { const scalarOrEnumTypes = ['String', 'Int', 'Float', 'Boolean', 'ID', ...enumTypes, ...customScalarTypes]; return type.kind === 'NamedType' && scalarOrEnumTypes.includes(type.name.value); } function inferGraphDatabaseDirectives(schemaModel, { queryPrefix = '', mutationPrefix = ''} = {}) { var currentType = ''; let referencedType = ''; let edgeName = ''; schemaModel.definitions .filter(definition => definition.kind === 'EnumTypeDefinition') .forEach(definition => enumTypes.push(definition.name.value)); // Generate sort enum typesToAdd.push(`enum SortDirection {\nASC\nDESC\n}`); schemaModel.definitions .filter(definition => definition.kind === 'ScalarTypeDefinition') .forEach(definition => customScalarTypes.push(definition.name.value)); schemaModel.definitions.forEach(def => { if (def.kind == 'ObjectTypeDefinition') { if (!(def.name.value == 'Query' || def.name.value == 'Mutation')) { currentType = def.name.value; // Only add _id field to the object type if it doesn't have an ID field already if (!getIdFieldWithDirective(def)) { // there is no field with @id directive const idFieldByType = getIdFieldByType(def); if (idFieldByType) { // there is an ID field, add the missing @id directive idFieldByType.directives.unshift(createIdDirective()); } else { def.fields.unshift(createIdFieldWithDirective()); } } addNode(def,{ queryPrefix, mutationPrefix }); const edgesTypeToAdd = []; // add relationships def.fields.forEach(field => { if (field.type.type !== undefined) { // FIXME handle NonNullType wrapper if (field.type.type.kind === 'NamedType' && field.type.type.name.value !== 'ID') { try { if (field.type.kind === 'ListType') addFilterOptionsSortArguments(field); } catch {} try { referencedType = field.type.type.name.value; edgeName = referencedType + 'Edge'; loggerInfo("Infer graph database directive in type: " + yellow(currentType) + " field: " + yellow(field.name.value) + " referenced type: " + yellow(referencedType) + " graph relationship: " + yellow(edgeName)); addRelationshipDirective(field, edgeName, 'OUT'); addEdge(currentType, referencedType, edgeName, { mutationPrefix }); if (!edgesTypeToAdd.includes(edgeName)) edgesTypeToAdd.push(edgeName); } catch {} } } else if (field.type.name.value !== 'String' && field.type.name.value !== 'Int' && field.type.name.value !== 'Float' && field.type.name.value !== 'Boolean' && !enumTypes.includes(field.type.name.value) && !customScalarTypes.includes(field.type.name.value)) { referencedType = field.type.name.value; edgeName = referencedType + 'Edge'; loggerInfo("Infer graph database directive in type: " + yellow(currentType) + " field: " + yellow(field.name.value) + " referenced type: " + yellow(referencedType) + " graph relationship: " + yellow(edgeName)); addRelationshipDirective(field, edgeName, 'OUT'); addEdge(currentType, referencedType, edgeName, { mutationPrefix }); if (!edgesTypeToAdd.includes(edgeName)) edgesTypeToAdd.push(edgeName); } }); // add edges edgesTypeToAdd.forEach(edgeName => { def.fields.push({ kind: "FieldDefinition", name: { kind: "Name", value: lowercaseFirstCharacter(edgeName) }, arguments: [], type: { kind: "NamedType", name: { kind: "Name", value: edgeName } }, directives: [] }); }); } } }); typesToAdd.push('input Options {\n' + '\tlimit: Int\n' + '\toffset: Int\n' + '}\n'); typesToAdd.push('input StringScalarFilters {\n' + '\teq: String\n' + '\tcontains: String\n' + '\tendsWith: String\n' + '\tstartsWith: String\n' + '}\n'); return injectChanges(schemaModel); } function validatedSchemaModel (schemaModel, { queryPrefix = '', mutationPrefix = ''} = {}) { if (!isGraphDBDirectives(schemaModel)) { loggerInfo("The schema model does not contain any graph database directives."); schemaModel = inferGraphDatabaseDirectives(schemaModel, { queryPrefix, mutationPrefix }); } return schemaModel; } export { validatedSchemaModel };