@omnigraph/grpc
Version:
476 lines (475 loc) • 22.4 kB
JavaScript
import globby from 'globby';
import { specifiedDirectives } from 'graphql';
import { SchemaComposer, } from 'graphql-compose';
import { GraphQLBigInt, GraphQLByte, GraphQLJSON, GraphQLUnsignedInt, GraphQLVoid, } from 'graphql-scalars';
import micromatch from 'micromatch';
import protobufjs from 'protobufjs';
import descriptor from 'protobufjs/ext/descriptor/index.js';
import { Client } from '@ardatan/grpc-reflection-js';
import { fs, path } from '@graphql-mesh/cross-helpers';
import { stringInterpolator } from '@graphql-mesh/string-interpolation';
import { GraphQLStreamDirective } from '@graphql-tools/utils';
import { credentials } from '@grpc/grpc-js';
import { DisposableStack } from '@whatwg-node/disposablestack';
import { EnumDirective, grpcConnectivityStateDirective, grpcMethodDirective, transportDirective, } from './directives.js';
import { addIncludePathResolver, getTypeName, walkToFindTypePath } from './utils.js';
const { Root } = protobufjs;
const QUERY_METHOD_PREFIXES = ['get', 'list', 'search'];
export class GrpcLoaderHelper extends DisposableStack {
constructor(subgraphName, baseDir, logger, config) {
super();
this.subgraphName = subgraphName;
this.baseDir = baseDir;
this.logger = logger;
this.config = config;
this.schemaComposer = new SchemaComposer();
}
async buildSchema() {
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.config.requestTimeout = this.config.requestTimeout || 200000;
this.logger.debug(`Getting channel credentials`);
const creds = await this.getCredentials();
if ('_unref' in creds && typeof creds._unref === 'function') {
this.defer(() => creds._unref());
}
this.logger.debug(`Getting stored root and decoded descriptor set objects`);
const descriptorSets = await this.getDescriptorSets(creds);
const directives = [];
const roots = [];
for (const { name: rootJsonName, rootJson } of descriptorSets) {
const rootLogger = this.logger.child({ root: rootJsonName });
this.logger.debug(`Building the schema structure based on the root object`);
this.visit({
nested: rootJson,
name: '',
currentPath: [],
rootJsonName,
rootJson,
rootLogger,
});
roots.push({ name: rootJsonName, rootJson: JSON.stringify(rootJson) });
}
this.schemaComposer.Query.setDirectives(directives);
// graphql-compose doesn't add @defer and @stream to the schema
specifiedDirectives.forEach(directive => this.schemaComposer.addDirective(directive));
if (!this.schemaComposer.hasDirective('stream')) {
this.schemaComposer.addDirective(GraphQLStreamDirective);
}
this.logger.debug(`Building the final GraphQL Schema`);
this.schemaComposer.addDirective(transportDirective);
const schema = this.schemaComposer.buildSchema();
const schemaExtensions = (schema.extensions = schema.extensions || {});
const directiveExtensions = (schemaExtensions.directives = schemaExtensions.directives || {});
directiveExtensions.transport = {
subgraph: this.subgraphName,
kind: 'grpc',
location: this.config.endpoint,
options: {
requestTimeout: this.config.requestTimeout,
credentialsSsl: this.config.credentialsSsl,
useHTTPS: this.config.useHTTPS,
metaData: this.config.metaData,
roots,
},
};
return schema;
}
processReflection(creds) {
this.logger.debug(`Using the reflection`);
const reflectionEndpoint = stringInterpolator.parse(this.config.endpoint, { env: process.env });
this.logger.debug(`Creating gRPC Reflection Client`);
const reflectionClient = new Client(reflectionEndpoint, creds);
this.defer(() => reflectionClient.grpcClient.close());
return reflectionClient.listServices().then(services => services.filter(service => service && !service?.startsWith('grpc.')).map(service => {
this.logger.debug(`Resolving root of Service: ${service} from the reflection response`);
return reflectionClient.fileContainingSymbol(service);
}));
}
async processDescriptorFile() {
let fileName;
let options;
if (typeof this.config.source === 'object') {
fileName = this.config.source.file;
options = {
...this.config.source.load,
includeDirs: this.config.source.load.includeDirs?.map(includeDir => path.isAbsolute(includeDir) ? includeDir : path.join(this.baseDir, includeDir)),
};
}
else {
fileName = this.config.source;
}
fileName = stringInterpolator.parse(fileName, { env: process.env });
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() {
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: this.config.source.load?.includeDirs?.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;
}
fileGlob = stringInterpolator.parse(fileGlob, { env: process.env });
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;
}
async getDescriptorSets(creds) {
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({ root: 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,
}),
};
}));
}
getCredentials() {
this.logger.debug(`Getting channel credentials`);
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));
}
return Promise.all(sslFiles).then(([rootCA, privateKey, certChain]) => 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();
}
visit({ nested, name, currentPath, rootJsonName, rootJson, rootLogger: logger, }) {
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,
rootJsonName,
rootJson,
rootLogger: logger,
});
}
}
const typeName = pathWithName.join('__');
if ('values' in nested) {
const enumValues = {};
const commentMap = nested.comments;
for (const [key, value] of Object.entries(nested.values)) {
logger.debug(`Visiting ${currentPath}.nested.values[${key}]`);
enumValues[key] = {
directives: [
{
name: 'enum',
args: {
subgraph: this.subgraphName,
value: JSON.stringify(value),
},
},
],
description: commentMap?.[key],
};
}
this.schemaComposer.addDirective(EnumDirective);
this.schemaComposer.createEnumTC({
name: typeName,
values: enumValues,
description: nested.comment,
});
}
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 = 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 = 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('.');
for (const methodName in nested.methods) {
const method = nested.methods[methodName];
const rootFieldName = [...pathWithName, methodName].join('_');
const fieldConfigTypeFactory = () => {
const baseResponseTypePath = method.responseType?.split('.');
if (baseResponseTypePath) {
const responseTypePath = walkToFindTypePath(rootJson, pathWithName, baseResponseTypePath);
return getTypeName(this.schemaComposer, responseTypePath, false);
}
return 'Void';
};
const fieldConfig = {
type: () => {
const typeName = fieldConfigTypeFactory();
if (method.responseStream) {
return `[${typeName}]`;
}
return typeName;
},
description: method.comment,
};
const fieldConfigArgs = {
input: () => {
if (method.requestStream) {
return 'File';
}
const baseRequestTypePath = method.requestType?.split('.');
if (baseRequestTypePath) {
const requestTypePath = walkToFindTypePath(rootJson, pathWithName, baseRequestTypePath);
const requestTypeName = getTypeName(this.schemaComposer, requestTypePath, true);
return requestTypeName;
}
return undefined;
},
};
fieldConfig.args = fieldConfigArgs;
const methodNameLowerCased = methodName.toLowerCase();
const prefixQueryMethod = this.config.prefixQueryMethod || QUERY_METHOD_PREFIXES;
let rootTypeComposer;
if (this.config.selectQueryOrMutationField) {
const selection = this.config.selectQueryOrMutationField.find(selection => micromatch([rootFieldName], selection.fieldName).length > 0);
const rootTypeName = selection?.type?.toLowerCase();
if (rootTypeName) {
if (rootTypeName === 'query') {
rootTypeComposer = this.schemaComposer.Query;
}
else if (rootTypeName === 'mutation') {
rootTypeComposer = this.schemaComposer.Mutation;
}
else if (rootTypeName === 'subscription') {
rootTypeComposer = this.schemaComposer.Subscription;
}
else {
throw new Error(`Unknown type provided ${selection.type} for ${rootFieldName}; available options are Query, Mutation and Subscription`);
}
}
}
if (rootTypeComposer == null) {
rootTypeComposer = prefixQueryMethod.some(prefix => methodNameLowerCased.startsWith(prefix))
? this.schemaComposer.Query
: this.schemaComposer.Mutation;
}
this.schemaComposer.addDirective(grpcMethodDirective);
rootTypeComposer.addFields({
[rootFieldName]: {
...fieldConfig,
directives: [
{
name: 'grpcMethod',
args: {
subgraph: this.subgraphName,
rootJsonName,
objPath,
methodName,
responseStream: !!method.responseStream,
},
},
],
},
});
if (method.responseStream) {
this.schemaComposer.Subscription.addFields({
[rootFieldName]: {
args: fieldConfigArgs,
description: method.comment,
type: fieldConfigTypeFactory,
directives: [
{
name: 'grpcMethod',
args: {
subgraph: this.subgraphName,
rootJsonName,
objPath,
methodName,
responseStream: true,
},
},
],
},
});
}
}
const connectivityStateFieldName = pathWithName.join('_') + '_connectivityState';
this.schemaComposer.addDirective(grpcConnectivityStateDirective);
this.schemaComposer.Query.addFields({
[connectivityStateFieldName]: {
type: 'ConnectivityState',
args: {
tryToConnect: {
type: 'Boolean',
},
},
directives: [
{
name: 'grpcConnectivityState',
args: {
subgraph: this.subgraphName,
rootJsonName,
objPath,
},
},
],
},
});
}
}
}