@graphql-mesh/grpc
Version:
451 lines (450 loc) • 20.9 kB
JavaScript
/* eslint-disable import/no-duplicates */
import globby from 'globby';
import { specifiedDirectives } from 'graphql';
import { SchemaComposer } from 'graphql-compose';
import { GraphQLBigInt, GraphQLByte, GraphQLJSON, GraphQLUnsignedInt, GraphQLVoid, } from 'graphql-scalars';
import lodashGet from 'lodash.get';
import lodashHas from 'lodash.has';
import protobufjs from 'protobufjs';
import descriptor from 'protobufjs/ext/descriptor/index.js';
import { Client } from '@ardatan/grpc-reflection-js';
import { path, process } from '@graphql-mesh/cross-helpers';
import { fs } from '@graphql-mesh/cross-helpers';
import { stringInterpolator } from '@graphql-mesh/string-interpolation';
import { credentials, loadPackageDefinition } from '@grpc/grpc-js';
import { fromJSON } from '@grpc/proto-loader';
import './patchLongJs.js';
import { addIncludePathResolver, addMetaDataToCall, getTypeName } from './utils.js';
const { Root } = protobufjs;
const QUERY_METHOD_PREFIXES = ['get', 'list', 'search'];
export default class GrpcHandler {
constructor({ config, baseDir, store, logger, pubsub, }) {
this.schemaComposer = new SchemaComposer();
this.logger = logger;
this.config = config;
this.baseDir = baseDir;
this.rootJsonEntries = store.proxy('rootJsonEntries', {
codify: rootJsonEntries => `
export default [
${rootJsonEntries
.map(({ name, rootJson }) => `
{
name: ${JSON.stringify(name)},
rootJson: ${JSON.stringify(rootJson, null, 2)},
},
`)
.join('\n')}
];
`.trim(),
fromJSON: jsonData => {
return jsonData.map(({ name, rootJson }) => ({
name,
rootJson,
}));
},
toJSON: rootJsonEntries => {
return rootJsonEntries.map(({ name, rootJson }) => {
return {
name,
rootJson,
};
});
},
validate: () => { },
});
this.pubsub = pubsub;
}
async processReflection(creds) {
this.logger.debug(`Using the reflection`);
const grpcReflectionServer = this.config.endpoint;
this.logger.debug(`Creating gRPC Reflection Client`);
const reflectionClient = new Client(grpcReflectionServer, creds);
const subId = this.pubsub.subscribe('destroy', () => {
reflectionClient.grpcClient.close();
this.pubsub.unsubscribe(subId);
});
const services = await reflectionClient.listServices();
const userServices = services.filter(service => service && !(service === null || service === void 0 ? void 0 : service.startsWith('grpc.')));
return userServices.map(async (service) => {
this.logger.debug(`Resolving root of Service: ${service} from the reflection response`);
const serviceRoot = await reflectionClient.fileContainingSymbol(service);
return serviceRoot;
});
}
async processDescriptorFile() {
var _a;
let fileName;
let options;
if (typeof this.config.source === 'object') {
fileName = this.config.source.file;
options = {
...this.config.source.load,
includeDirs: (_a = this.config.source.load.includeDirs) === null || _a === void 0 ? void 0 : _a.map(includeDir => path.isAbsolute(includeDir) ? includeDir : path.join(this.baseDir, includeDir)),
};
}
else {
fileName = this.config.source;
}
const absoluteFilePath = path.isAbsolute(fileName)
? fileName
: path.join(this.baseDir, fileName);
this.logger.debug(`Using the descriptor set from ${absoluteFilePath} `);
const descriptorSetBuffer = await fs.promises.readFile(absoluteFilePath);
this.logger.debug(`Reading ${absoluteFilePath} `);
let decodedDescriptorSet;
if (absoluteFilePath.endsWith('json')) {
this.logger.debug(`Parsing ${absoluteFilePath} as json`);
const descriptorSetJSON = JSON.parse(descriptorSetBuffer.toString());
decodedDescriptorSet = descriptor.FileDescriptorSet.fromObject(descriptorSetJSON);
}
else {
decodedDescriptorSet = descriptor.FileDescriptorSet.decode(descriptorSetBuffer);
}
this.logger.debug(`Creating root from descriptor set`);
const rootFromDescriptor = Root.fromDescriptor(decodedDescriptorSet);
if (options.includeDirs) {
if (!Array.isArray(options.includeDirs)) {
return Promise.reject(new Error('The includeDirs option must be an array'));
}
addIncludePathResolver(rootFromDescriptor, options.includeDirs);
}
return rootFromDescriptor;
}
async processProtoFile() {
var _a, _b;
this.logger.debug(`Using proto file(s)`);
let protoRoot = new Root();
let fileGlob;
let options = {
keepCase: true,
alternateCommentMode: true,
};
if (typeof this.config.source === 'object') {
fileGlob = this.config.source.file;
options = {
...options,
...this.config.source.load,
includeDirs: (_b = (_a = this.config.source.load) === null || _a === void 0 ? void 0 : _a.includeDirs) === null || _b === void 0 ? void 0 : _b.map(includeDir => path.isAbsolute(includeDir) ? includeDir : path.join(this.baseDir, includeDir)),
};
if (options.includeDirs) {
if (!Array.isArray(options.includeDirs)) {
throw new Error('The includeDirs option must be an array');
}
addIncludePathResolver(protoRoot, options.includeDirs);
}
}
else {
fileGlob = this.config.source;
}
const fileNames = await globby(fileGlob, {
cwd: this.baseDir,
});
this.logger.debug(`Loading proto files(${fileGlob}); \n ${fileNames.join('\n')} `);
protoRoot = await protoRoot.load(fileNames.map(filePath => path.isAbsolute(filePath) ? filePath : path.join(this.baseDir, filePath)), options);
this.logger.debug(`Adding proto content to the root`);
return protoRoot;
}
getCachedDescriptorSets(creds) {
return this.rootJsonEntries.getWithSet(async () => {
const rootPromises = [];
this.logger.debug(`Building Roots`);
if (this.config.source) {
const filePath = typeof this.config.source === 'string' ? this.config.source : this.config.source.file;
if (filePath.endsWith('json')) {
rootPromises.push(this.processDescriptorFile());
}
else if (filePath.endsWith('proto')) {
rootPromises.push(this.processProtoFile());
}
}
else {
const reflectionPromises = await this.processReflection(creds);
rootPromises.push(...reflectionPromises);
}
return Promise.all(rootPromises.map(async (root$, i) => {
const root = await root$;
const rootName = root.name || `Root${i}`;
const rootLogger = this.logger.child(rootName);
rootLogger.debug(`Resolving entire the root tree`);
root.resolveAll();
rootLogger.debug(`Creating artifacts from descriptor set and root`);
return {
name: rootName,
rootJson: root.toJSON({
keepComments: true,
}),
};
}));
});
}
async getCredentials() {
if (this.config.credentialsSsl) {
this.logger.debug(() => `Using SSL Connection with credentials at ${this.config.credentialsSsl.privateKey} & ${this.config.credentialsSsl.certChain}`);
const absolutePrivateKeyPath = path.isAbsolute(this.config.credentialsSsl.privateKey)
? this.config.credentialsSsl.privateKey
: path.join(this.baseDir, this.config.credentialsSsl.privateKey);
const absoluteCertChainPath = path.isAbsolute(this.config.credentialsSsl.certChain)
? this.config.credentialsSsl.certChain
: path.join(this.baseDir, this.config.credentialsSsl.certChain);
const sslFiles = [
fs.promises.readFile(absolutePrivateKeyPath),
fs.promises.readFile(absoluteCertChainPath),
];
if (this.config.credentialsSsl.rootCA !== 'rootCA') {
const absoluteRootCAPath = path.isAbsolute(this.config.credentialsSsl.rootCA)
? this.config.credentialsSsl.rootCA
: path.join(this.baseDir, this.config.credentialsSsl.rootCA);
sslFiles.unshift(fs.promises.readFile(absoluteRootCAPath));
}
const [rootCA, privateKey, certChain] = await Promise.all(sslFiles);
return credentials.createSsl(rootCA, privateKey, certChain);
}
else if (this.config.useHTTPS) {
this.logger.debug(`Using SSL Connection`);
return credentials.createSsl();
}
this.logger.debug(`Using insecure connection`);
return credentials.createInsecure();
}
walkToFindTypePath(rootJson, pathWithName, baseTypePath) {
const currentWalkingPath = [...pathWithName];
while (!lodashHas(rootJson.nested, currentWalkingPath.concat(baseTypePath).join('.nested.'))) {
if (!currentWalkingPath.length) {
break;
}
currentWalkingPath.pop();
}
return currentWalkingPath.concat(baseTypePath);
}
visit({ nested, name, currentPath, rootJson, creds, grpcObject, rootLogger: logger, }) {
var _a;
const pathWithName = [...currentPath, ...name.split('.')].filter(Boolean);
if ('nested' in nested) {
for (const key in nested.nested) {
logger.debug(`Visiting ${currentPath}.nested[${key}]`);
const currentNested = nested.nested[key];
this.visit({
nested: currentNested,
name: key,
currentPath: pathWithName,
rootJson,
creds,
grpcObject,
rootLogger: logger,
});
}
}
const typeName = pathWithName.join('_');
if ('values' in nested) {
const enumTypeConfig = {
name: typeName,
values: {},
description: nested.comment,
};
const commentMap = nested.comments;
for (const [key, value] of Object.entries(nested.values)) {
logger.debug(`Visiting ${currentPath}.nested.values[${key}]`);
enumTypeConfig.values[key] = {
value,
description: commentMap === null || commentMap === void 0 ? void 0 : commentMap[key],
};
}
this.schemaComposer.createEnumTC(enumTypeConfig);
}
else if ('fields' in nested) {
const inputTypeName = typeName + '_Input';
const outputTypeName = typeName;
const description = nested.comment;
const fieldEntries = Object.entries(nested.fields);
if (fieldEntries.length) {
const inputTC = this.schemaComposer.createInputTC({
name: inputTypeName,
description,
fields: {},
});
const outputTC = this.schemaComposer.createObjectTC({
name: outputTypeName,
description,
fields: {},
});
for (const [fieldName, { type, rule, comment, keyType }] of fieldEntries) {
logger.debug(`Visiting ${currentPath}.nested.fields[${fieldName}]`);
const baseFieldTypePath = type.split('.');
inputTC.addFields({
[fieldName]: {
type: () => {
let fieldInputTypeName;
if (keyType) {
fieldInputTypeName = 'JSON';
}
else {
const fieldTypePath = this.walkToFindTypePath(rootJson, pathWithName, baseFieldTypePath);
fieldInputTypeName = getTypeName(this.schemaComposer, fieldTypePath, true);
}
return rule === 'repeated' ? `[${fieldInputTypeName}]` : fieldInputTypeName;
},
description: comment,
},
});
outputTC.addFields({
[fieldName]: {
type: () => {
let fieldTypeName;
if (keyType) {
fieldTypeName = 'JSON';
}
else {
const fieldTypePath = this.walkToFindTypePath(rootJson, pathWithName, baseFieldTypePath);
fieldTypeName = getTypeName(this.schemaComposer, fieldTypePath, false);
}
return rule === 'repeated' ? `[${fieldTypeName}]` : fieldTypeName;
},
description: comment,
},
});
}
}
else {
this.schemaComposer.createScalarTC({
...GraphQLJSON.toConfig(),
name: inputTypeName,
description,
});
this.schemaComposer.createScalarTC({
...GraphQLJSON.toConfig(),
name: outputTypeName,
description,
});
}
}
else if ('methods' in nested) {
const objPath = pathWithName.join('.');
const ServiceClient = lodashGet(grpcObject, objPath);
if (typeof ServiceClient !== 'function') {
throw new Error(`Object at path ${objPath} is not a Service constructor`);
}
const client = new ServiceClient((_a = stringInterpolator.parse(this.config.endpoint, { env: process.env })) !== null && _a !== void 0 ? _a : this.config.endpoint, creds);
const subId = this.pubsub.subscribe('destroy', () => {
client.close();
this.pubsub.unsubscribe(subId);
});
for (const methodName in nested.methods) {
const method = nested.methods[methodName];
const rootFieldName = [...pathWithName, methodName].join('_');
const fieldConfig = {
type: () => {
var _a;
const baseResponseTypePath = (_a = method.responseType) === null || _a === void 0 ? void 0 : _a.split('.');
if (baseResponseTypePath) {
const responseTypePath = this.walkToFindTypePath(rootJson, pathWithName, baseResponseTypePath);
let typeName = getTypeName(this.schemaComposer, responseTypePath, false);
if (method.responseStream) {
typeName = `[${typeName}]`;
}
return typeName;
}
return 'Void';
},
description: method.comment,
};
fieldConfig.args = {
input: () => {
var _a;
if (method.requestStream) {
return 'File';
}
const baseRequestTypePath = (_a = method.requestType) === null || _a === void 0 ? void 0 : _a.split('.');
if (baseRequestTypePath) {
const requestTypePath = this.walkToFindTypePath(rootJson, pathWithName, baseRequestTypePath);
const requestTypeName = getTypeName(this.schemaComposer, requestTypePath, true);
return requestTypeName;
}
return undefined;
},
};
const methodNameLowerCased = methodName.toLowerCase();
const prefixQueryMethod = this.config.prefixQueryMethod || QUERY_METHOD_PREFIXES;
const rootTypeComposer = prefixQueryMethod.some(prefix => methodNameLowerCased.startsWith(prefix))
? this.schemaComposer.Query
: this.schemaComposer.Mutation;
rootTypeComposer.addFields({
[rootFieldName]: {
...fieldConfig,
resolve: (root, args, context, info) => addMetaDataToCall(client[methodName].bind(client), args.input, {
root,
args,
context,
env: process.env,
}, this.config.metaData, !!method.responseStream),
},
});
}
const connectivityStateFieldName = pathWithName.join('_') + '_connectivityState';
this.schemaComposer.Query.addFields({
[connectivityStateFieldName]: {
type: 'ConnectivityState',
args: {
tryToConnect: {
type: 'Boolean',
},
},
resolve: (_, { tryToConnect }) => client.getChannel().getConnectivityState(tryToConnect),
},
});
}
}
async getMeshSource() {
this.config.requestTimeout = this.config.requestTimeout || 200000;
this.schemaComposer.add(GraphQLBigInt);
this.schemaComposer.add(GraphQLByte);
this.schemaComposer.add(GraphQLUnsignedInt);
this.schemaComposer.add(GraphQLVoid);
this.schemaComposer.add(GraphQLJSON);
this.schemaComposer.createScalarTC({
name: 'File',
});
// identical of grpc's ConnectivityState
this.schemaComposer.createEnumTC({
name: 'ConnectivityState',
values: {
IDLE: { value: 0 },
CONNECTING: { value: 1 },
READY: { value: 2 },
TRANSIENT_FAILURE: { value: 3 },
SHUTDOWN: { value: 4 },
},
});
this.logger.debug(`Getting channel credentials`);
const creds = await this.getCredentials();
this.logger.debug(`Getting stored root and decoded descriptor set objects`);
const artifacts = await this.getCachedDescriptorSets(creds);
for (const { name, rootJson } of artifacts) {
const rootLogger = this.logger.child(name);
rootLogger.debug(`Creating package definition from file descriptor set object`);
let options;
if (typeof this.config.source === 'object') {
options = this.config.source.load;
}
const packageDefinition = fromJSON(rootJson, options);
rootLogger.debug(`Creating service client for package definition`);
const grpcObject = loadPackageDefinition(packageDefinition);
this.logger.debug(`Building the schema structure based on the root object`);
this.visit({
nested: rootJson,
name: '',
currentPath: [],
rootJson,
creds,
grpcObject,
rootLogger,
});
}
// graphql-compose doesn't add @defer and @stream to the schema
specifiedDirectives.forEach(directive => this.schemaComposer.addDirective(directive));
this.logger.debug(`Building the final GraphQL Schema`);
const schema = this.schemaComposer.buildSchema();
return {
schema,
};
}
}