diff --git a/.eslintignore b/.eslintignore index ab16bd6..f2f6ce2 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,2 +1,2 @@ -build/src/reflection_* +build/* src/reflection_* diff --git a/test/client.test.ts b/test/client.test.ts index 84a0ba4..9bb9198 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -1,3 +1,4 @@ +import {describe, before, after, it} from 'mocha'; import {Client} from '../src/client'; import {credentials} from '@postman/grpc-js'; // eslint-disable-next-line node/no-unpublished-import @@ -10,135 +11,166 @@ import { FileDescriptorResponse, } from '../src/reflection_providers/v1/reflection_pb'; -// eslint-disable-next-line no-undef -describe('listServices', () => { - // eslint-disable-next-line no-undef - it('should return services', async () => { - const reflectionClient = new Client( - 'localhost:4770', - credentials.createInsecure() - ); - - const grpcCall = { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - on: function (_event: string, listener: (...args: any[]) => void) { - const res = new ServerReflectionResponse(); - - const service1 = new ServiceResponse(); - service1.setName('grpc.reflection.v1alpha.ServerReflection'); - const service2 = new ServiceResponse(); - service2.setName('phone.Messenger'); - const serviceList = [service1, service2]; - - const listService = new ListServiceResponse(); - listService.setServiceList(serviceList); - res.setListServicesResponse(listService); - - listener(res); - }, - write: function () {}, - end: function () {}, - }; - - const mock = sinon.mock(reflectionClient.grpcClient); - mock.expects('serverReflectionInfo').once().returns(grpcCall); - - const expectedServices: string[] = [ - 'grpc.reflection.v1alpha.ServerReflection', - 'phone.Messenger', - ]; - assert.sameMembers(await reflectionClient.listServices(), expectedServices); +describe('server reflection tests', () => { + const reflectionMethodsToTest = [ + {name: 'v1', port: 4771}, + {name: 'v1alpha', port: 4772}, + ]; + + let mockV1Server: {port: number; shutdown: () => Promise}; + let mockV1AlphaServer: {port: number; shutdown: () => Promise}; + + before(async () => { + const v1ServerImport = await import('./test-servers/v1-server'), + v1AlphaServerImport = await import('./test-servers/v1alpha-server'); + + mockV1Server = await v1ServerImport.start(4771); + mockV1AlphaServer = await v1AlphaServerImport.start(4772); }); -}); -// eslint-disable-next-line no-undef -describe('fileContainingSymbol', () => { - // eslint-disable-next-line no-undef - it('should return Root', async () => { - const reflectionClient = new Client( - 'localhost:4770', - credentials.createInsecure() - ); - - const grpcCallPhone = { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - on: function (_event: string, listener: (...args: any[]) => void) { - if (_event === 'error') { - return; - } - - const res = new ServerReflectionResponse(); - const fileDescriptorResponse = new FileDescriptorResponse(); - // eslint-disable-next-line prettier/prettier - const protoBytes = Buffer.from([10,11,112,104,111,110,101,46,112,114,111,116,111,18,5,112,104,111,110,101,26,13,99,111,110,116,97,99,116,46,112,114,111,116,111,34,97,10,11,84,101,120,116,82,101,113,117,101,115,116,18,14,10,2,105,100,24,1,32,1,40,9,82,2,105,100,18,24,10,7,109,101,115,115,97,103,101,24,2,32,1,40,9,82,7,109,101,115,115,97,103,101,18,40,10,7,99,111,110,116,97,99,116,24,3,32,1,40,11,50,14,46,112,104,111,110,101,46,67,111,110,116,97,99,116,82,7,99,111,110,116,97,99,116,34,40,10,12,84,101,120,116,82,101,115,112,111,110,115,101,18,24,10,7,115,117,99,99,101,115,115,24,1,32,1,40,8,82,7,115,117,99,99,101,115,115,50,63,10,9,77,101,115,115,101,110,103,101,114,18,50,10,7,77,101,115,115,97,103,101,18,18,46,112,104,111,110,101,46,84,101,120,116,82,101,113,117,101,115,116,26,19,46,112,104,111,110,101,46,84,101,120,116,82,101,115,112,111,110,115,101,98,6,112,114,111,116,111,51]); - fileDescriptorResponse.addFileDescriptorProto(protoBytes); - res.setFileDescriptorResponse(fileDescriptorResponse); - - listener(res); - }, - write: function () {}, - end: function () {}, - }; - - const grpcCallContact = { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - on: function (_event: string, listener: (...args: any[]) => void) { - if (_event === 'error') { - return; - } - - const res = new ServerReflectionResponse(); - const fileDescriptorResponse = new FileDescriptorResponse(); - // eslint-disable-next-line prettier/prettier - const protoBytes = Buffer.from([10,13,99,111,110,116,97,99,116,46,112,114,111,116,111,18,5,112,104,111,110,101,34,53,10,7,67,111,110,116,97,99,116,18,18,10,4,110,97,109,101,24,1,32,1,40,9,82,4,110,97,109,101,18,22,10,6,110,117,109,98,101,114,24,2,32,1,40,9,82,6,110,117,109,98,101,114,98,6,112,114,111,116,111,51]); - fileDescriptorResponse.addFileDescriptorProto(protoBytes); - res.setFileDescriptorResponse(fileDescriptorResponse); - - listener(res); - }, - write: function () {}, - end: function () {}, - }; - - const mock = sinon.mock(reflectionClient.grpcClient); - mock.expects('serverReflectionInfo').once().returns(grpcCallPhone); - mock.expects('serverReflectionInfo').once().returns(grpcCallContact); - const root = await reflectionClient.fileContainingSymbol('phone.Messenger'); - assert.sameDeepMembers(root.files, ['contact.proto', 'phone.proto']); + after(async () => { + await mockV1Server.shutdown(); + await mockV1AlphaServer.shutdown(); + }); + + describe('listServices', async () => { + for (const version of reflectionMethodsToTest) { + it(`should return services: ${version.name}`, async () => { + const reflectionClient = new Client( + `0.0.0.0:${version.port}`, + credentials.createInsecure() + ); + + await reflectionClient.initialize(); + + const grpcCall = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + on: function (_event: string, listener: (...args: any[]) => void) { + const res = new ServerReflectionResponse(); + + const service1 = new ServiceResponse(); + service1.setName('grpc.reflection.v1alpha.ServerReflection'); + const service2 = new ServiceResponse(); + service2.setName('phone.Messenger'); + const serviceList = [service1, service2]; + + const listService = new ListServiceResponse(); + listService.setServiceList(serviceList); + res.setListServicesResponse(listService); + + listener(res); + }, + write: function () {}, + end: function () {}, + }; + + const mock = sinon.mock(reflectionClient.grpcClient); + mock.expects('serverReflectionInfo').once().returns(grpcCall); + + const expectedServices: string[] = ['phone.Messenger']; + assert.sameMembers( + await reflectionClient.listServices(), + expectedServices + ); + }); + } + }); + + describe('fileContainingSymbol', () => { + for (const version of reflectionMethodsToTest) { + it(`should return Root: ${version.name}`, async () => { + const reflectionClient = new Client( + `0.0.0.0:${version.port}`, + credentials.createInsecure() + ); + + await reflectionClient.initialize(); + + const grpcCallPhone = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + on: function (_event: string, listener: (...args: any[]) => void) { + if (_event === 'error') { + return; + } + + const res = new ServerReflectionResponse(); + const fileDescriptorResponse = new FileDescriptorResponse(); + // eslint-disable-next-line prettier/prettier + const protoBytes = Buffer.from([10,11,112,104,111,110,101,46,112,114,111,116,111,18,5,112,104,111,110,101,26,13,99,111,110,116,97,99,116,46,112,114,111,116,111,34,97,10,11,84,101,120,116,82,101,113,117,101,115,116,18,14,10,2,105,100,24,1,32,1,40,9,82,2,105,100,18,24,10,7,109,101,115,115,97,103,101,24,2,32,1,40,9,82,7,109,101,115,115,97,103,101,18,40,10,7,99,111,110,116,97,99,116,24,3,32,1,40,11,50,14,46,112,104,111,110,101,46,67,111,110,116,97,99,116,82,7,99,111,110,116,97,99,116,34,40,10,12,84,101,120,116,82,101,115,112,111,110,115,101,18,24,10,7,115,117,99,99,101,115,115,24,1,32,1,40,8,82,7,115,117,99,99,101,115,115,50,63,10,9,77,101,115,115,101,110,103,101,114,18,50,10,7,77,101,115,115,97,103,101,18,18,46,112,104,111,110,101,46,84,101,120,116,82,101,113,117,101,115,116,26,19,46,112,104,111,110,101,46,84,101,120,116,82,101,115,112,111,110,115,101,98,6,112,114,111,116,111,51]); + fileDescriptorResponse.addFileDescriptorProto(protoBytes); + res.setFileDescriptorResponse(fileDescriptorResponse); + + listener(res); + }, + write: function () {}, + end: function () {}, + }; + + const grpcCallContact = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + on: function (_event: string, listener: (...args: any[]) => void) { + if (_event === 'error') { + return; + } + + const res = new ServerReflectionResponse(); + const fileDescriptorResponse = new FileDescriptorResponse(); + // eslint-disable-next-line prettier/prettier + const protoBytes = Buffer.from([10,13,99,111,110,116,97,99,116,46,112,114,111,116,111,18,5,112,104,111,110,101,34,53,10,7,67,111,110,116,97,99,116,18,18,10,4,110,97,109,101,24,1,32,1,40,9,82,4,110,97,109,101,18,22,10,6,110,117,109,98,101,114,24,2,32,1,40,9,82,6,110,117,109,98,101,114,98,6,112,114,111,116,111,51]); + fileDescriptorResponse.addFileDescriptorProto(protoBytes); + res.setFileDescriptorResponse(fileDescriptorResponse); + + listener(res); + }, + write: function () {}, + end: function () {}, + }; + + const mock = sinon.mock(reflectionClient.grpcClient); + mock.expects('serverReflectionInfo').once().returns(grpcCallPhone); + mock.expects('serverReflectionInfo').once().returns(grpcCallContact); + const root = await reflectionClient.fileContainingSymbol( + 'phone.Messenger' + ); + assert.sameDeepMembers(root.files, ['contact.proto', 'phone.proto']); + }); + } }); -}); -// eslint-disable-next-line no-undef -describe('fileByFilename', () => { - // eslint-disable-next-line no-undef - it('should return Root', async () => { - const reflectionClient = new Client( - 'localhost:4770', - credentials.createInsecure() - ); - - const grpcCallContact = { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - on: function (_event: string, listener: (...args: any[]) => void) { - if (_event === 'error') { - return; - } - const res = new ServerReflectionResponse(); - const fileDescriptorResponse = new FileDescriptorResponse(); - // eslint-disable-next-line prettier/prettier - const protoBytes = Buffer.from([10,13,99,111,110,116,97,99,116,46,112,114,111,116,111,18,5,112,104,111,110,101,34,53,10,7,67,111,110,116,97,99,116,18,18,10,4,110,97,109,101,24,1,32,1,40,9,82,4,110,97,109,101,18,22,10,6,110,117,109,98,101,114,24,2,32,1,40,9,82,6,110,117,109,98,101,114,98,6,112,114,111,116,111,51]); - fileDescriptorResponse.addFileDescriptorProto(protoBytes); - res.setFileDescriptorResponse(fileDescriptorResponse); - - listener(res); - }, - write: function () {}, - end: function () {}, - }; - - const mock = sinon.mock(reflectionClient.grpcClient); - mock.expects('serverReflectionInfo').once().returns(grpcCallContact); - const root = await reflectionClient.fileByFilename('contact.proto'); - assert.deepEqual(root.files, ['contact.proto']); + describe('fileByFilename', () => { + for (const version of reflectionMethodsToTest) { + it(`should return Root: ${version.name}`, async () => { + const reflectionClient = new Client( + `0.0.0.0:${version.port}`, + credentials.createInsecure() + ); + + await reflectionClient.initialize(); + + const grpcCallContact = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + on: function (_event: string, listener: (...args: any[]) => void) { + if (_event === 'error') { + return; + } + const res = new ServerReflectionResponse(); + const fileDescriptorResponse = new FileDescriptorResponse(); + // eslint-disable-next-line prettier/prettier + const protoBytes = Buffer.from([10,13,99,111,110,116,97,99,116,46,112,114,111,116,111,18,5,112,104,111,110,101,34,53,10,7,67,111,110,116,97,99,116,18,18,10,4,110,97,109,101,24,1,32,1,40,9,82,4,110,97,109,101,18,22,10,6,110,117,109,98,101,114,24,2,32,1,40,9,82,6,110,117,109,98,101,114,98,6,112,114,111,116,111,51]); + fileDescriptorResponse.addFileDescriptorProto(protoBytes); + res.setFileDescriptorResponse(fileDescriptorResponse); + + listener(res); + }, + write: function () {}, + end: function () {}, + }; + + const mock = sinon.mock(reflectionClient.grpcClient); + mock.expects('serverReflectionInfo').once().returns(grpcCallContact); + const root = await reflectionClient.fileByFilename('contact.proto'); + assert.deepEqual(root.files, ['contact.proto']); + }); + } }); }); diff --git a/test/fixtures/server_descriptor.protoset b/test/fixtures/server_descriptor.protoset new file mode 100644 index 0000000..b3013d2 --- /dev/null +++ b/test/fixtures/server_descriptor.protoset @@ -0,0 +1,14 @@ + +¯ +test/fixtures/phone.protophone"5 +Contact +name ( Rname +number ( Rnumber"a + TextRequest +id ( Rid +message ( Rmessage( +contact ( 2.phone.ContactRcontact"( + TextResponse +success (Rsuccess2? + Messenger2 +Message.phone.TextRequest.phone.TextResponsebproto3 \ No newline at end of file diff --git a/test/test-servers/base.js b/test/test-servers/base.js new file mode 100644 index 0000000..16db72a --- /dev/null +++ b/test/test-servers/base.js @@ -0,0 +1,290 @@ +function createServer({apiVersion = 'v1'} = {}) { + const grpc = require('@postman/grpc-js'); + + // eslint-disable-next-line node/no-extraneous-require + const protoLoader = require('@postman/proto-loader'); + const path = require('path'); + const fs = require('fs'); + const descriptor = require('google-protobuf/google/protobuf/descriptor_pb'); + + const PROTO_DIR = path.join(__dirname, '../fixtures'); + const PHONE_PROTO_PATH = path.join(PROTO_DIR, 'phone.proto'); + const REFLECTION_PROTO_PATH = path.join( + __dirname, + '../../static/grpc/reflection', + apiVersion, + 'reflection.proto' + ); + const DESCRIPTOR_SET_PATH = path.join( + PROTO_DIR, + 'server_descriptor.protoset' + ); + + let fileDescriptorProtos = []; + + const protosByFilename = new Map(); + const filenameBySymbol = new Map(); + + const serviceNames = []; + + let server, boundPort; + + async function start(port = 0) { + if (server) return Promise.resolve({server, port: boundPort, shutdown}); + + const descriptorSetBytes = fs.readFileSync(DESCRIPTOR_SET_PATH); + const fileDescriptorSet = + descriptor.FileDescriptorSet.deserializeBinary(descriptorSetBytes); + fileDescriptorProtos = fileDescriptorSet.getFileList(); + + if (!fileDescriptorProtos || fileDescriptorProtos.length === 0) { + throw new Error('Descriptor set is empty or failed to parse.'); + } + + fileDescriptorProtos.forEach(proto => { + const filename = proto.getName(); + if (!filename) return; + + protosByFilename.set(filename, proto); + + const packageName = proto.getPackage(); + + function buildSymbolName(baseName) { + return packageName ? `${packageName}.${baseName}` : baseName; + } + + proto.getMessageTypeList().forEach(messageType => { + const fqMessageName = buildSymbolName(messageType.getName()); + filenameBySymbol.set(fqMessageName, filename); + }); + + proto.getEnumTypeList().forEach(enumType => { + const fqEnumName = buildSymbolName(enumType.getName()); + filenameBySymbol.set(fqEnumName, filename); + }); + + proto.getServiceList().forEach(service => { + const fqServiceName = buildSymbolName(service.getName()); + filenameBySymbol.set(fqServiceName, filename); + serviceNames.push(fqServiceName); + }); + }); + + const packageDefinitionOptions = { + keepCase: true, + longs: String, + enums: String, + defaults: true, + oneofs: true, + includeDirs: [PROTO_DIR], + }; + const phonePackageDefinition = protoLoader.loadSync( + PHONE_PROTO_PATH, + packageDefinitionOptions + ); + const phoneProto = grpc.loadPackageDefinition(phonePackageDefinition).phone; + + const reflectionPackageDefinition = protoLoader.loadSync( + REFLECTION_PROTO_PATH, + packageDefinitionOptions + ); + + const reflectionProto = grpc.loadPackageDefinition( + reflectionPackageDefinition + ).grpc.reflection[apiVersion]; + + const phoneServiceImplementation = {message: () => {}}; + + function getDependenciesRecursive( + filename, + protosByFilenameMap, + visited = new Set() + ) { + if (visited.has(filename)) { + return []; + } + visited.add(filename); + + const proto = protosByFilenameMap.get(filename); + if (!proto) { + return []; + } + + const directDependencies = proto.getDependencyList(); + const allDependencies = [...directDependencies]; + + directDependencies.forEach(depFilename => { + const transitiveDeps = getDependenciesRecursive( + depFilename, + protosByFilenameMap, + visited + ); + transitiveDeps.forEach(transitiveDep => { + if (!allDependencies.includes(transitiveDep)) { + allDependencies.push(transitiveDep); + } + }); + }); + + const finalDeps = allDependencies.filter(dep => dep !== filename); + + return finalDeps; + } + + const reflectionServiceImpl = { + serverReflectionInfo: call => { + call.on('data', request => { + const responseObject = { + valid_host: request.host, + original_request: request, + + list_services_response: null, + file_descriptor_response: null, + error_response: null, + }; + + try { + const messageRequestCase = request.message_request; + + if (messageRequestCase === 'list_services') { + const listResponseObj = {service: []}; + serviceNames.forEach(name => { + listResponseObj.service.push({name: name}); + }); + responseObject.list_services_response = listResponseObj; + } else if (messageRequestCase === 'file_by_filename') { + const filename = request.file_by_filename; + const targetProto = protosByFilename.get(filename); + + if (!targetProto) { + responseObject.error_response = { + error_code: grpc.status.NOT_FOUND, + error_message: `File not found: ${filename}`, + }; + } else { + const dependencies = getDependenciesRecursive( + filename, + protosByFilename + ); + const protosToSend = [targetProto]; + + dependencies.forEach(depFilename => { + const depProto = protosByFilename.get(depFilename); + if (depProto) { + protosToSend.push(depProto); + } + }); + + const fileDescriptorResponseObj = {file_descriptor_proto: []}; + protosToSend.forEach(proto => { + fileDescriptorResponseObj.file_descriptor_proto.push( + proto.serializeBinary() + ); + }); + responseObject.file_descriptor_response = + fileDescriptorResponseObj; + } + } else if (messageRequestCase === 'file_containing_symbol') { + const symbol = request.file_containing_symbol; + const filename = filenameBySymbol.get(symbol); + + if (!filename) { + responseObject.error_response = { + error_code: grpc.status.NOT_FOUND, + error_message: `Symbol not found: ${symbol}`, + }; + } else { + const targetProto = protosByFilename.get(filename); + if (!targetProto) { + throw new Error( + `Internal error: Symbol '${symbol}' maps to filename '${filename}' but proto not found.` + ); + } + const dependencies = getDependenciesRecursive( + filename, + protosByFilename + ); + const protosToSend = [targetProto]; + dependencies.forEach(depFilename => { + const depProto = protosByFilename.get(depFilename); + if (depProto) protosToSend.push(depProto); + }); + + const fileDescriptorResponseObj = {file_descriptor_proto: []}; + protosToSend.forEach(proto => { + fileDescriptorResponseObj.file_descriptor_proto.push( + proto.serializeBinary() + ); + }); + responseObject.file_descriptor_response = + fileDescriptorResponseObj; + } + } else { + responseObject.error_response = { + error_code: grpc.status.UNIMPLEMENTED, + error_message: `Request type '${messageRequestCase}' not implemented`, + }; + } + + call.write(responseObject); + } catch (error) { + const errorRespWrapper = { + valid_host: request.host, + original_request: request, + error_response: { + error_code: grpc.status.INTERNAL, + error_message: `Error processing reflection request: ${error.message}`, + }, + }; + call.write(errorRespWrapper); + } + }); + + call.on('end', () => { + call.end(); + }); + }, + }; + + const grpcServer = new grpc.Server(); + + grpcServer.addService( + phoneProto.Messenger.service, + phoneServiceImplementation + ); + grpcServer.addService( + reflectionProto.ServerReflection.service, + reflectionServiceImpl + ); + + return new Promise((resolve, reject) => { + grpcServer.bindAsync( + `0.0.0.0:${port}`, + grpc.ServerCredentials.createInsecure(), + (err, receivedPort) => { + if (err) return reject(err); + grpcServer.start(); + server = grpcServer; + boundPort = receivedPort; + resolve({server: grpcServer, port: boundPort, shutdown}); + } + ); + }); + } + + async function shutdown() { + if (!server) return Promise.resolve(); + + return new Promise(resolve => { + if (!server) return resolve(); + server.tryShutdown(err => { + if (err) server.forceShutdown(); + resolve(); + }); + }); + } + + return {start, shutdown}; +} + +module.exports = createServer; diff --git a/test/test-servers/v1-server.js b/test/test-servers/v1-server.js new file mode 100644 index 0000000..c4989d0 --- /dev/null +++ b/test/test-servers/v1-server.js @@ -0,0 +1,3 @@ +const createServer = require('./base'); + +module.exports = createServer({apiVersion: 'v1'}); diff --git a/test/test-servers/v1alpha-server.js b/test/test-servers/v1alpha-server.js new file mode 100644 index 0000000..aa338f6 --- /dev/null +++ b/test/test-servers/v1alpha-server.js @@ -0,0 +1,3 @@ +const createServer = require('./base'); + +module.exports = createServer({apiVersion: 'v1alpha'});