diff --git a/typescript/src/generator.ts b/typescript/src/generator.ts index b3a37139b..fab714786 100644 --- a/typescript/src/generator.ts +++ b/typescript/src/generator.ts @@ -46,6 +46,9 @@ export class Generator { paramMap: OptionsMap; // This field is for users passing proper publish package name like @google-cloud/text-to-speech. publishName?: string; + // For historical reasons, Webpack library name matches "the main" service of the client library. + // Sometimes it's hard to figure out automatically, so making this an option. + mainServiceName?: string; constructor() { this.request = plugin.google.protobuf.compiler.CodeGeneratorRequest.create(); @@ -99,10 +102,12 @@ export class Generator { } } - private async readPublishPackageName(map: OptionsMap) { - if (map?.['package-name']) { - this.publishName = map['package-name']; - } + private readPublishPackageName(map: OptionsMap) { + this.publishName = map['package-name']; + } + + private readMainServiceName(map: OptionsMap) { + this.mainServiceName = map['main-service']; } async initializeFromStdin() { @@ -113,7 +118,8 @@ export class Generator { if (this.request.parameter) { this.getParamMap(this.request.parameter); await this.readGrpcServiceConfig(this.paramMap); - await this.readPublishPackageName(this.paramMap); + this.readPublishPackageName(this.paramMap); + this.readMainServiceName(this.paramMap); } } @@ -146,12 +152,11 @@ export class Generator { if (packageName === '') { throw new Error('Cannot get package name to generate.'); } - const api = new API( - this.request.protoFile, - packageName, - this.grpcServiceConfig, - this.publishName - ); + const api = new API(this.request.protoFile, packageName, { + grpcServiceConfig: this.grpcServiceConfig, + publishName: this.publishName, + mainServiceName: this.mainServiceName, + }); return api; } @@ -161,8 +166,6 @@ export class Generator { } async generate() { - const fileToGenerate = this.request.fileToGenerate; - this.response = plugin.google.protobuf.compiler.CodeGeneratorResponse.create(); this.addProtosToResponse(); diff --git a/typescript/src/schema/api.ts b/typescript/src/schema/api.ts index bb99db444..154b6c7db 100644 --- a/typescript/src/schema/api.ts +++ b/typescript/src/schema/api.ts @@ -32,17 +32,20 @@ export class API { protos: ProtosMap; hostName?: string; port?: string; - mainServiceName?: string; // This field is for users passing proper publish package name like @google-cloud/text-to-speech. publishName: string; - // oauth_scopes: plugin.google.protobuf.IServiceOptions.prototype[".google.api.oauthScopes"]; - // TODO: subpackages + // For historical reasons, Webpack library name matches "the main" service of the client library. + // Sometimes it's hard to figure out automatically, so making this an option. + mainServiceName: string; constructor( fileDescriptors: plugin.google.protobuf.IFileDescriptorProto[], packageName: string, - grpcServiceConfig: plugin.grpc.service_config.ServiceConfig, - publishName?: string + options: { + grpcServiceConfig: plugin.grpc.service_config.ServiceConfig; + publishName?: string; + mainServiceName?: string; + } ) { this.naming = new Naming( fileDescriptors.filter( @@ -50,7 +53,8 @@ export class API { ) ); // users specify the actual package name, if not, set it to product name. - this.publishName = publishName || this.naming.productName.toKebabCase(); + this.publishName = + options.publishName || this.naming.productName.toKebabCase(); // construct resource map const resourceMap = getResourceDatabase(fileDescriptors); // parse resource map to Proto constructor @@ -61,27 +65,36 @@ export class API { map[fd.name!] = new Proto( fd, packageName, - grpcServiceConfig, + options.grpcServiceConfig, resourceMap ); return map; }, {} as ProtosMap); - fileDescriptors.forEach(fd => { - if (fd.service) { - fd.service.forEach(service => { - if (service.options) { - const serviceOption = service.options; - if (serviceOption['.google.api.defaultHost']) { - const defaultHost = serviceOption['.google.api.defaultHost']; - const arr = defaultHost.split(':'); - this.hostName = arr[0] || 'localhost'; - this.port = arr.length > 1 ? arr[1] : '443'; - this.mainServiceName = service.name || this.naming.name; - } - } - }); - } - }); + + const serviceNamesList: string[] = []; + fileDescriptors + .filter(fd => fd.service) + .reduce((servicesList, fd) => { + servicesList.push(...fd.service!); + return servicesList; + }, [] as plugin.google.protobuf.IServiceDescriptorProto[]) + .filter(service => service?.options?.['.google.api.defaultHost']) + .sort((service1, service2) => + service1.name!.localeCompare(service2.name!) + ) + .forEach(service => { + const defaultHost = service!.options!['.google.api.defaultHost']!; + const [hostname, port] = defaultHost.split(':'); + if (hostname && this.hostName && hostname !== this.hostName) { + console.warn( + `Warning: different hostnames ${hostname} and ${this.hostName} within the same client are not supported.` + ); + } + this.hostName = hostname || this.hostName || 'localhost'; + this.port = port ?? this.port ?? '443'; + serviceNamesList.push(service.name || this.naming.name); + }); + this.mainServiceName = options.mainServiceName || serviceNamesList[0]; } get services() { @@ -93,7 +106,10 @@ export class API { ...Object.keys(proto.services).map(name => proto.services[name]) ); return retval; - }, [] as plugin.google.protobuf.IServiceDescriptorProto[]); + }, [] as plugin.google.protobuf.IServiceDescriptorProto[]) + .sort((service1, service2) => + service1.name!.localeCompare(service2.name!) + ); } get filesToGenerate() { diff --git a/typescript/src/start_script.ts b/typescript/src/start_script.ts index e4af8ca17..a04abf744 100755 --- a/typescript/src/start_script.ts +++ b/typescript/src/start_script.ts @@ -36,6 +36,11 @@ const argv = yargs .describe('grpc-service-config', 'Path to gRPC service config JSON') .alias('package-name', 'package_name') .describe('package-name', 'Publish package name') + .alias('main-service', 'main_service') + .describe( + 'main_service', + 'Main service name (if the package has multiple services, this one will be used for Webpack bundle name)' + ) .alias('common-proto-path', 'common_protos_path') .describe( 'common_proto_path', @@ -46,7 +51,7 @@ const argv = yargs const outputDir = argv.outputDir as string; const grpcServiceConfig = argv.grpcServiceConfig as string | undefined; const packageName = argv.packageName as string | undefined; - +const mainServiceName = argv.mainService as string | undefined; const protoDirs: string[] = []; if (argv.I) { protoDirs.push(...(argv.I as string[])); @@ -77,6 +82,11 @@ if (grpcServiceConfig) { if (packageName) { protocCommand.push(`--typescript_gapic_opt="package-name=${packageName}"`); } +if (mainServiceName) { + protocCommand.push( + `--typescript_gapic_opt="main-service=${mainServiceName}"` + ); +} protocCommand.push(...protoDirsArg); protocCommand.push(...protoFiles); try { diff --git a/typescript/test/testdata/showcase/protos/google/showcase/v1beta1/echo.proto.baseline b/typescript/test/testdata/showcase/protos/google/showcase/v1beta1/echo.proto.baseline new file mode 100644 index 000000000..e316c3aa1 --- /dev/null +++ b/typescript/test/testdata/showcase/protos/google/showcase/v1beta1/echo.proto.baseline @@ -0,0 +1,177 @@ +// Copyright 2018 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License 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. + +syntax = "proto3"; + +import "google/api/annotations.proto"; +import "google/api/client.proto"; +import "google/api/field_behavior.proto"; +import "google/longrunning/operations.proto"; +import "google/protobuf/duration.proto"; +import "google/protobuf/timestamp.proto"; +import "google/rpc/status.proto"; + +package google.showcase.v1beta1; + +option go_package = "github.com/googleapis/gapic-showcase/server/genproto"; +option java_package = "com.google.showcase.v1beta1"; +option java_multiple_files = true; + +// This service is used showcase the four main types of rpcs - unary, server +// side streaming, client side streaming, and bidirectional streaming. This +// service also exposes methods that explicitly implement server delay, and +// paginated calls. +service Echo { + // This service is meant to only run locally on the port 7469 (keypad digits + // for "show"). + option (google.api.default_host) = "localhost:7469"; + + // This method simply echos the request. This method is showcases unary rpcs. + rpc Echo(EchoRequest) returns (EchoResponse) { + option (google.api.http) = { + post: "/v1beta1/echo:echo" + body: "*" + }; + } + + // This method split the given content into words and will pass each word back + // through the stream. This method showcases server-side streaming rpcs. + rpc Expand(ExpandRequest) returns (stream EchoResponse) { + option (google.api.http) = { + post: "/v1beta1/echo:expand" + body: "*" + }; + // TODO(landrito): change this to be `fields: ["content", "error"]` once + // github.com/dcodeIO/protobuf.js/issues/1094 has been resolved. + option (google.api.method_signature) = "content,error"; + } + + // This method will collect the words given to it. When the stream is closed + // by the client, this method will return the a concatenation of the strings + // passed to it. This method showcases client-side streaming rpcs. + rpc Collect(stream EchoRequest) returns (EchoResponse) { + option (google.api.http) = { + post: "/v1beta1/echo:collect" + body: "*" + }; + } + + // This method, upon receiving a request on the stream, the same content will + // be passed back on the stream. This method showcases bidirectional + // streaming rpcs. + rpc Chat(stream EchoRequest) returns (stream EchoResponse); + + // This is similar to the Expand method but instead of returning a stream of + // expanded words, this method returns a paged list of expanded words. + rpc PagedExpand(PagedExpandRequest) returns (PagedExpandResponse) { + option (google.api.http) = { + post: "/v1beta1/echo:pagedExpand" + body: "*" + }; + } + + // This method will wait the requested amount of and then return. + // This method showcases how a client handles a request timing out. + rpc Wait(WaitRequest) returns (google.longrunning.Operation) { + option (google.api.http) = { + post: "/v1beta1/echo:wait" + body: "*" + }; + option (google.longrunning.operation_info) = { + response_type: "WaitResponse" + metadata_type: "WaitMetadata" + }; + } +} + +// The request message used for the Echo, Collect and Chat methods. If content +// is set in this message then the request will succeed. If status is set in +// this message then the status will be returned as an error. +message EchoRequest { + oneof response { + // The content to be echoed by the server. + string content = 1; + + // The error to be thrown by the server. + google.rpc.Status error = 2; + } +} + +// The response message for the Echo methods. +message EchoResponse { + // The content specified in the request. + string content = 1; +} + +// The request message for the Expand method. +message ExpandRequest { + // The content that will be split into words and returned on the stream. + string content = 1; + + // The error that is thrown after all words are sent on the stream. + google.rpc.Status error = 2; +} + +// The request for the PagedExpand method. +message PagedExpandRequest { + // The string to expand. + string content = 1 [(google.api.field_behavior) = REQUIRED]; + + // The amount of words to returned in each page. + int32 page_size = 2; + + // The position of the page to be returned. + string page_token = 3; +} + +// The response for the PagedExpand method. +message PagedExpandResponse { + // The words that were expanded. + repeated EchoResponse responses = 1; + + // The next page token. + string next_page_token = 2; +} + +// The request for Wait method. +message WaitRequest { + oneof end { + // The time that this operation will complete. + google.protobuf.Timestamp end_time = 1; + + // The duration of this operation. + google.protobuf.Duration ttl = 4; + } + + oneof response { + // The error that will be returned by the server. If this code is specified + // to be the OK rpc code, an empty response will be returned. + google.rpc.Status error = 2; + + // The response to be returned on operation completion. + WaitResponse success = 3; + } +} + +// The result of the Wait operation. +message WaitResponse { + // This content of the result. + string content = 1; +} + +// The metadata for Wait operation. +message WaitMetadata { + // The time that this operation will complete. + google.protobuf.Timestamp end_time =1; +} diff --git a/typescript/test/testdata/showcase/webpack.config.js.baseline b/typescript/test/testdata/showcase/webpack.config.js.baseline index e57f6afe7..8486ad470 100644 --- a/typescript/test/testdata/showcase/webpack.config.js.baseline +++ b/typescript/test/testdata/showcase/webpack.config.js.baseline @@ -17,8 +17,8 @@ const path = require('path'); module.exports = { entry: './src/index.ts', output: { - library: 'Echo', - filename: './echo.js', + library: 'ShowcaseService', + filename: './showcase-service.js', }, node: { child_process: 'empty', diff --git a/typescript/test/unit/api.ts b/typescript/test/unit/api.ts index dedf1c49b..9cece1564 100644 --- a/typescript/test/unit/api.ts +++ b/typescript/test/unit/api.ts @@ -22,11 +22,9 @@ describe('schema/api.ts', () => { fd.name = 'google/cloud/test/v1/test.proto'; fd.package = 'google.cloud.test.v1'; fd.service = [new plugin.google.protobuf.ServiceDescriptorProto()]; - const api = new API( - [fd], - 'google.cloud.test.v1', - new plugin.grpc.service_config.ServiceConfig() - ); + const api = new API([fd], 'google.cloud.test.v1', { + grpcServiceConfig: new plugin.grpc.service_config.ServiceConfig(), + }); assert.deepStrictEqual(api.filesToGenerate, [ 'google/cloud/test/v1/test.proto', ]); @@ -41,13 +39,58 @@ describe('schema/api.ts', () => { fd2.name = 'google/longrunning/operation.proto'; fd2.package = 'google.longrunning'; fd2.service = [new plugin.google.protobuf.ServiceDescriptorProto()]; - const api = new API( - [fd1, fd2], - 'google.cloud.test.v1', - new plugin.grpc.service_config.ServiceConfig() - ); + const api = new API([fd1, fd2], 'google.cloud.test.v1', { + grpcServiceConfig: new plugin.grpc.service_config.ServiceConfig(), + }); assert.deepStrictEqual(api.filesToGenerate, [ 'google/cloud/test/v1/test.proto', ]); }); + + it('should return lexicographically first service name as mainServiceName', () => { + const fd1 = new plugin.google.protobuf.FileDescriptorProto(); + fd1.name = 'google/cloud/test/v1/test.proto'; + fd1.package = 'google.cloud.test.v1'; + fd1.service = [new plugin.google.protobuf.ServiceDescriptorProto()]; + fd1.service[0].name = 'ZService'; + fd1.service[0].options = { + '.google.api.defaultHost': 'hostname.example.com:443', + }; + const fd2 = new plugin.google.protobuf.FileDescriptorProto(); + fd2.name = 'google/cloud/example/v1/example.proto'; + fd2.package = 'google.cloud.example.v1'; + fd2.service = [new plugin.google.protobuf.ServiceDescriptorProto()]; + fd2.service[0].name = 'AService'; + fd2.service[0].options = { + '.google.api.defaultHost': 'hostname.example.com:443', + }; + const api = new API([fd1, fd2], 'google.cloud.test.v1', { + grpcServiceConfig: new plugin.grpc.service_config.ServiceConfig(), + }); + assert.deepStrictEqual(api.mainServiceName, 'AService'); + }); + + it('should return main service name specificed as an option', () => { + const fd1 = new plugin.google.protobuf.FileDescriptorProto(); + fd1.name = 'google/cloud/test/v1/test.proto'; + fd1.package = 'google.cloud.test.v1'; + fd1.service = [new plugin.google.protobuf.ServiceDescriptorProto()]; + fd1.service[0].name = 'ZService'; + fd1.service[0].options = { + '.google.api.defaultHost': 'hostname.example.com:443', + }; + const fd2 = new plugin.google.protobuf.FileDescriptorProto(); + fd2.name = 'google/cloud/example/v1/example.proto'; + fd2.package = 'google.cloud.example.v1'; + fd2.service = [new plugin.google.protobuf.ServiceDescriptorProto()]; + fd2.service[0].name = 'AService'; + fd2.service[0].options = { + '.google.api.defaultHost': 'hostname.example.com:443', + }; + const api = new API([fd1, fd2], 'google.cloud.test.v1', { + grpcServiceConfig: new plugin.grpc.service_config.ServiceConfig(), + mainServiceName: 'OverriddenName', + }); + assert.deepStrictEqual(api.mainServiceName, 'OverriddenName'); + }); }); diff --git a/typescript/test/unit/baseline.ts b/typescript/test/unit/baseline.ts deleted file mode 100644 index ed26d0e5f..000000000 --- a/typescript/test/unit/baseline.ts +++ /dev/null @@ -1,83 +0,0 @@ -// Copyright 2019 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// https://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License 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 { execSync } from 'child_process'; -import * as fs from 'fs-extra'; -import * as path from 'path'; -import * as rimraf from 'rimraf'; -import { equalToBaseline } from '../util'; -import * as assert from 'assert'; - -const cwd = process.cwd(); - -const OUTPUT_DIR = path.join(cwd, '.test-out-showcase'); -const BASELINE_DIR = path.join( - __dirname, - '..', - '..', - '..', - 'typescript', - 'test', - 'testdata' -); -const BASELINE_DIR_SHOWCASE = path.join(BASELINE_DIR, 'showcase'); -const GOOGLE_GAX_PROTOS_DIR = path.join( - cwd, - 'node_modules', - 'google-gax', - 'protos' -); -const PROTOS_DIR = path.join(cwd, 'build', 'test', 'protos'); -const ECHO_PROTO_FILE = path.join( - PROTOS_DIR, - 'google', - 'showcase', - 'v1beta1', - 'echo.proto' -); -const SRCDIR = path.join(cwd, 'build', 'src'); -const CLI = path.join(SRCDIR, 'cli.js'); -const PLUGIN = path.join(SRCDIR, 'protoc-gen-typescript_gapic'); - -describe('CodeGeneratorBaselineTest', () => { - describe('Generate client library', () => { - it('Generated library should have same client with baseline.', function() { - this.timeout(10000); - if (fs.existsSync(OUTPUT_DIR)) { - rimraf.sync(OUTPUT_DIR); - } - fs.mkdirSync(OUTPUT_DIR); - - if (fs.existsSync(PLUGIN)) { - rimraf.sync(PLUGIN); - } - fs.copyFileSync(CLI, PLUGIN); - process.env['PATH'] = SRCDIR + path.delimiter + process.env['PATH']; - - try { - execSync(`chmod +x ${PLUGIN}`); - } catch (err) { - console.warn(`Failed to chmod +x ${PLUGIN}: ${err}. Ignoring...`); - } - - execSync( - `protoc --typescript_gapic_out=${OUTPUT_DIR} ` + - `-I${GOOGLE_GAX_PROTOS_DIR} ` + - `-I${PROTOS_DIR} ` + - ECHO_PROTO_FILE - ); - assert(equalToBaseline(OUTPUT_DIR, BASELINE_DIR_SHOWCASE)); - }); - }); -}); diff --git a/typescript/test/unit/starter_script_test.ts b/typescript/test/unit/starter_script_test.ts index 62daca6f2..70eb01180 100644 --- a/typescript/test/unit/starter_script_test.ts +++ b/typescript/test/unit/starter_script_test.ts @@ -28,7 +28,7 @@ const START_SCRIPT = path.join( 'src', 'start_script.js' ); -const OUTPUT_DIR = path.join(__dirname, '..', '..', '..', '.client_library'); +const OUTPUT_DIR = path.join(__dirname, '..', '..', '..', '.test-out-showcase'); const PROTOS_DIR = path.join(process.cwd(), 'build', 'test', 'protos'); const PROTO_FILE = path.join( PROTOS_DIR, @@ -61,7 +61,8 @@ describe('StarterScriptTest', () => { START_SCRIPT + ` -I${PROTOS_DIR}` + ` ${PROTO_FILE}` + - ` --output_dir=${OUTPUT_DIR}` + ` --output_dir=${OUTPUT_DIR}` + + ` --main_service=ShowcaseService` ); assert(equalToBaseline(OUTPUT_DIR, BASELINE_DIR_SHOWCASE)); });