diff --git a/packages/grpc-reflection/example/server.ts b/packages/grpc-reflection/example/server.ts new file mode 100644 index 00000000..fa691f76 --- /dev/null +++ b/packages/grpc-reflection/example/server.ts @@ -0,0 +1,22 @@ +import * as path from 'path'; +import * as grpc from '@grpc/grpc-js'; +import * as protoLoader from '@grpc/proto-loader'; + +import { ReflectionService } from '../src'; + +const PROTO_PATH = path.join(__dirname, '../proto/sample/sample.proto'); +const INCLUDE_PATH = path.join(__dirname, '../proto/sample/vendor'); + +const server = new grpc.Server(); +const packageDefinition = protoLoader.loadSync(PROTO_PATH, { includeDirs: [INCLUDE_PATH] }); +const reflection = new ReflectionService(packageDefinition); +reflection.addToServer(server); + +server.bindAsync('localhost:5000', grpc.ServerCredentials.createInsecure(), () => { + server.start(); +}); + +// const protoDescriptor = grpc.loadPackageDefinition(packageDefinition); + + + diff --git a/packages/grpc-reflection/package.json b/packages/grpc-reflection/package.json index b413ddda..8f8ea8ef 100644 --- a/packages/grpc-reflection/package.json +++ b/packages/grpc-reflection/package.json @@ -15,12 +15,10 @@ "email": "justinmtimmons@gmail.com" } ], + "main": "build/src/index.js", + "types": "build/src/index.d.ts", "files": [ - "LICENSE", - "README.md", - "src", - "build", - "proto" + "build" ], "license": "Apache-2.0", "scripts": { diff --git a/packages/grpc-reflection/src/constants.ts b/packages/grpc-reflection/src/constants.ts new file mode 100644 index 00000000..93ad2782 --- /dev/null +++ b/packages/grpc-reflection/src/constants.ts @@ -0,0 +1,14 @@ +import * as protoLoader from '@grpc/proto-loader'; + +/** Options to use when loading protobuf files in this repo +* +* @remarks *must* match the proto-loader-gen-types usage in the package.json +* otherwise the generated types may not match the data coming into this service +*/ +export const PROTO_LOADER_OPTS: protoLoader.Options = { + longs: String, + enums: String, + bytes: Array, + defaults: true, + oneofs: true +}; diff --git a/packages/grpc-reflection/src/index.ts b/packages/grpc-reflection/src/index.ts new file mode 100644 index 00000000..deba7fe3 --- /dev/null +++ b/packages/grpc-reflection/src/index.ts @@ -0,0 +1 @@ +export { ReflectionService } from './service'; diff --git a/packages/grpc-reflection/src/reflection-v1-implementation.ts b/packages/grpc-reflection/src/reflection-v1-implementation.ts index 311b4097..4f298f86 100644 --- a/packages/grpc-reflection/src/reflection-v1-implementation.ts +++ b/packages/grpc-reflection/src/reflection-v1-implementation.ts @@ -1,3 +1,4 @@ +import * as path from 'path'; import { FileDescriptorProto, FileDescriptorSet, @@ -9,8 +10,11 @@ import * as protoLoader from '@grpc/proto-loader'; import { ExtensionNumberResponse__Output } from './generated/grpc/reflection/v1/ExtensionNumberResponse'; import { FileDescriptorResponse__Output } from './generated/grpc/reflection/v1/FileDescriptorResponse'; import { ListServiceResponse__Output } from './generated/grpc/reflection/v1/ListServiceResponse'; +import { ServerReflectionRequest } from './generated/grpc/reflection/v1/ServerReflectionRequest'; +import { ServerReflectionResponse } from './generated/grpc/reflection/v1/ServerReflectionResponse'; import { visit } from './protobuf-visitor'; import { scope } from './utils'; +import { PROTO_LOADER_OPTS } from './constants'; export class ReflectionError extends Error { constructor( @@ -139,6 +143,70 @@ export class ReflectionV1Implementation { ); } + addToServer(server: Pick) { + const protoPath = path.join(__dirname, '../proto/grpc/reflection/v1/reflection.proto'); + const pkgDefinition = protoLoader.loadSync(protoPath, PROTO_LOADER_OPTS); + const pkg = grpc.loadPackageDefinition(pkgDefinition) as any; + + server.addService(pkg.grpc.reflection.v1.ServerReflection.service, { + ServerReflectionInfo: ( + stream: grpc.ServerDuplexStream + ) => { + stream.on('end', () => stream.end()); + + stream.on('data', (message: ServerReflectionRequest) => { + stream.write(this.handleServerReflectionRequest(message)); + }); + } + }); + } + + /** Assemble a response for a single server reflection request in the stream */ + handleServerReflectionRequest(message: ServerReflectionRequest): ServerReflectionResponse { + const response: ServerReflectionResponse = { + validHost: message.host, + originalRequest: message, + fileDescriptorResponse: undefined, + allExtensionNumbersResponse: undefined, + listServicesResponse: undefined, + errorResponse: undefined, + }; + + try { + if (message.listServices !== undefined) { + response.listServicesResponse = this.listServices(message.listServices); + } else if (message.fileContainingSymbol !== undefined) { + response.fileDescriptorResponse = this.fileContainingSymbol(message.fileContainingSymbol); + } else if (message.fileByFilename !== undefined) { + response.fileDescriptorResponse = this.fileByFilename(message.fileByFilename); + } else if (message.fileContainingExtension !== undefined) { + const { containingType, extensionNumber } = message.fileContainingExtension; + response.fileDescriptorResponse = this.fileContainingExtension(containingType, extensionNumber); + } else if (message.allExtensionNumbersOfType) { + response.allExtensionNumbersResponse = this.allExtensionNumbersOfType(message.allExtensionNumbersOfType); + } else { + throw new ReflectionError( + grpc.status.UNIMPLEMENTED, + `Unimplemented method for request: ${message}`, + ); + } + } catch (e) { + if (e instanceof ReflectionError) { + response.errorResponse = { + errorCode: e.statusCode, + errorMessage: e.message, + }; + } else { + response.errorResponse = { + errorCode: grpc.status.UNKNOWN, + errorMessage: 'Failed to process gRPC reflection request: unknown error', + }; + } + } + + return response; + } + /** List the full names of registered gRPC services * * note: the spec is unclear as to what the 'listServices' param can be; most diff --git a/packages/grpc-reflection/src/reflection-v1alpha.ts b/packages/grpc-reflection/src/reflection-v1alpha.ts new file mode 100644 index 00000000..b0d86e59 --- /dev/null +++ b/packages/grpc-reflection/src/reflection-v1alpha.ts @@ -0,0 +1,42 @@ +import * as path from 'path'; + +import * as grpc from '@grpc/grpc-js'; +import * as protoLoader from '@grpc/proto-loader'; + +import { ServerReflectionRequest } from './generated/grpc/reflection/v1/ServerReflectionRequest'; +import { ServerReflectionResponse } from './generated/grpc/reflection/v1/ServerReflectionResponse'; +import { PROTO_LOADER_OPTS } from './constants'; +import { ReflectionV1Implementation } from './reflection-v1-implementation'; + + +/** Analyzes a gRPC server and exposes methods to reflect on it + * + * NOTE: the files returned by this service may not match the handwritten ones 1:1. + * This is because proto-loader reorients files based on their package definition, + * combining any that have the same package. + * + * For example: if files 'a.proto' and 'b.proto' are both for the same package 'c' then + * we will always return a reference to a combined 'c.proto' instead of the 2 files. + * + * @remarks as the v1 and v1alpha specs are identical, this implementation extends v1 + * and just exposes it at the v1alpha package instead + */ +export class ReflectionV1AlphaImplementation extends ReflectionV1Implementation { + addToServer(server: Pick) { + const protoPath = path.join(__dirname, '../proto/grpc/reflection/v1alpha/reflection.proto'); + const pkgDefinition = protoLoader.loadSync(protoPath, PROTO_LOADER_OPTS); + const pkg = grpc.loadPackageDefinition(pkgDefinition) as any; + + server.addService(pkg.grpc.reflection.v1alpha.ServerReflection.service, { + ServerReflectionInfo: ( + stream: grpc.ServerDuplexStream + ) => { + stream.on('end', () => stream.end()); + + stream.on('data', (message: ServerReflectionRequest) => { + stream.write(this.handleServerReflectionRequest(message)); + }); + } + }); + } +} diff --git a/packages/grpc-reflection/src/service.ts b/packages/grpc-reflection/src/service.ts new file mode 100644 index 00000000..7f08610b --- /dev/null +++ b/packages/grpc-reflection/src/service.ts @@ -0,0 +1,55 @@ +import * as grpc from '@grpc/grpc-js'; +import * as protoLoader from '@grpc/proto-loader'; + +import { ReflectionV1Implementation } from './reflection-v1-implementation'; +import { ReflectionV1AlphaImplementation } from './reflection-v1alpha'; + +interface ReflectionServerOptions { + /** whitelist of fully-qualified service names to expose. (Default: expose all) */ + services?: string[]; +} + +/** Analyzes a gRPC package and exposes endpoints providing information about + * it according to the gRPC Server Reflection API Specification + * + * @see https://github.com/grpc/grpc/blob/master/doc/server-reflection.md + * + * @remarks + * + * in order to keep backwards compatibility as the reflection schema evolves + * this service contains implementations for each of the published versions + * + * @privateRemarks + * + * this class acts mostly as a facade to several underlying implementations. This + * allows us to add or remove support for different versions of the reflection + * schema without affecting the consumer + * + */ +export class ReflectionService { + private readonly v1: ReflectionV1Implementation; + private readonly v1Alpha: ReflectionV1AlphaImplementation; + + constructor(pkg: protoLoader.PackageDefinition, options?: ReflectionServerOptions) { + + if (options.services) { + const whitelist = new Set(options.services); + + for (const key in Object.keys(pkg)) { + const value = pkg[key]; + const isService = value.format !== 'Protocol Buffer 3 DescriptorProto' && value.format !== 'Protocol Buffer 3 EnumDescriptorProto'; + if (isService && !whitelist.has(key)) { + delete pkg[key]; + } + } + } + + this.v1 = new ReflectionV1Implementation(pkg); + this.v1Alpha = new ReflectionV1AlphaImplementation(pkg); + } + + addToServer(server: Pick) { + this.v1.addToServer(server); + this.v1Alpha.addToServer(server); + } +}