diff --git a/protoc_plugin/lib/protoc.dart b/protoc_plugin/lib/protoc.dart index 794c0476..fa0fab26 100644 --- a/protoc_plugin/lib/protoc.dart +++ b/protoc_plugin/lib/protoc.dart @@ -12,9 +12,13 @@ import 'mixins.dart'; import 'names.dart'; import 'src/code_generator.dart'; import 'src/gen/dart_options.pb.dart'; +import 'src/gen/google/api/annotations.pb.dart'; import 'src/gen/google/api/client.pb.dart'; +import 'src/gen/google/api/http.pb.dart'; +import 'src/gen/google/api/routing.pb.dart'; import 'src/gen/google/protobuf/compiler/plugin.pb.dart'; import 'src/gen/google/protobuf/descriptor.pb.dart'; +import 'src/grpc_annotations.dart'; import 'src/linker.dart'; import 'src/options.dart'; import 'src/output_config.dart'; diff --git a/protoc_plugin/lib/src/code_generator.dart b/protoc_plugin/lib/src/code_generator.dart index 8d4acad0..1fd9c484 100644 --- a/protoc_plugin/lib/src/code_generator.dart +++ b/protoc_plugin/lib/src/code_generator.dart @@ -12,7 +12,9 @@ import 'package:protobuf/protobuf.dart'; import '../names.dart' show lowerCaseFirstLetter; import '../protoc.dart' show FileGenerator; import 'gen/dart_options.pb.dart'; +import 'gen/google/api/annotations.pb.dart'; import 'gen/google/api/client.pb.dart'; +import 'gen/google/api/routing.pb.dart'; import 'gen/google/protobuf/compiler/plugin.pb.dart'; import 'linker.dart'; import 'options.dart'; @@ -90,6 +92,11 @@ class CodeGenerator { Dart_options.registerAllExtensions(extensions); Client.registerAllExtensions(extensions); + // The 'http' annotation. + Annotations.registerAllExtensions(extensions); + // todo: firestore might not use the 'routing' annotation + // The 'routing' annotation. + Routing.registerAllExtensions(extensions); final builder = await _streamIn.fold( BytesBuilder(), diff --git a/protoc_plugin/lib/src/gen/google/api/annotations.pb.dart b/protoc_plugin/lib/src/gen/google/api/annotations.pb.dart new file mode 100644 index 00000000..e80b5d9c --- /dev/null +++ b/protoc_plugin/lib/src/gen/google/api/annotations.pb.dart @@ -0,0 +1,37 @@ +// This is a generated file - do not edit. +// +// Generated from google/api/annotations.proto. + +// @dart = 3.3 + +// ignore_for_file: annotate_overrides, camel_case_types, comment_references +// ignore_for_file: constant_identifier_names +// ignore_for_file: curly_braces_in_flow_control_structures +// ignore_for_file: deprecated_member_use_from_same_package, library_prefixes +// ignore_for_file: non_constant_identifier_names + +import 'dart:core' as $core; + +import 'package:protobuf/protobuf.dart' as $pb; + +import 'http.pb.dart' as $0; + +export 'package:protobuf/protobuf.dart' show GeneratedMessageGenericExtensions; + +class Annotations { + static final http = $pb.Extension<$0.HttpRule>( + _omitMessageNames ? '' : 'google.protobuf.MethodOptions', + _omitFieldNames ? '' : 'http', + 72295728, + $pb.PbFieldType.OM, + defaultOrMaker: $0.HttpRule.getDefault, + subBuilder: $0.HttpRule.create); + static void registerAllExtensions($pb.ExtensionRegistry registry) { + registry.add(http); + } +} + +const $core.bool _omitFieldNames = + $core.bool.fromEnvironment('protobuf.omit_field_names'); +const $core.bool _omitMessageNames = + $core.bool.fromEnvironment('protobuf.omit_message_names'); diff --git a/protoc_plugin/lib/src/gen/google/api/annotations.pbenum.dart b/protoc_plugin/lib/src/gen/google/api/annotations.pbenum.dart new file mode 100644 index 00000000..2beef33f --- /dev/null +++ b/protoc_plugin/lib/src/gen/google/api/annotations.pbenum.dart @@ -0,0 +1,11 @@ +// This is a generated file - do not edit. +// +// Generated from google/api/annotations.proto. + +// @dart = 3.3 + +// ignore_for_file: annotate_overrides, camel_case_types, comment_references +// ignore_for_file: constant_identifier_names +// ignore_for_file: curly_braces_in_flow_control_structures +// ignore_for_file: deprecated_member_use_from_same_package, library_prefixes +// ignore_for_file: non_constant_identifier_names diff --git a/protoc_plugin/lib/src/gen/google/protobuf/descriptor.pb.dart b/protoc_plugin/lib/src/gen/google/protobuf/descriptor.pb.dart index fe6bf471..cb26c6e6 100644 --- a/protoc_plugin/lib/src/gen/google/protobuf/descriptor.pb.dart +++ b/protoc_plugin/lib/src/gen/google/protobuf/descriptor.pb.dart @@ -2406,7 +2406,7 @@ class FieldOptions extends $pb.GeneratedMessage { $core.bool? deprecated, $core.bool? lazy, FieldOptions_JSType? jstype, - $core.bool? weak, + @$core.Deprecated('This field is deprecated.') $core.bool? weak, $core.bool? unverifiedLazy, $core.bool? debugRedact, FieldOptions_OptionRetention? retention, @@ -2605,13 +2605,18 @@ class FieldOptions extends $pb.GeneratedMessage { @$pb.TagNumber(6) void clearJstype() => $_clearField(6); + /// DEPRECATED. DO NOT USE! /// For Google-internal migration only. Do not use. + @$core.Deprecated('This field is deprecated.') @$pb.TagNumber(10) $core.bool get weak => $_getBF(5); + @$core.Deprecated('This field is deprecated.') @$pb.TagNumber(10) set weak($core.bool value) => $_setBool(5, value); + @$core.Deprecated('This field is deprecated.') @$pb.TagNumber(10) $core.bool hasWeak() => $_has(5); + @$core.Deprecated('This field is deprecated.') @$pb.TagNumber(10) void clearWeak() => $_clearField(10); diff --git a/protoc_plugin/lib/src/grpc_annotations.dart b/protoc_plugin/lib/src/grpc_annotations.dart new file mode 100644 index 00000000..d4f90d3d --- /dev/null +++ b/protoc_plugin/lib/src/grpc_annotations.dart @@ -0,0 +1,247 @@ +// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:collection/collection.dart'; + +import 'gen/google/api/http.pb.dart'; + +class PathTemplate { + final List segments; + final String? verb; + + PathTemplate({required this.segments, this.verb}); + + factory PathTemplate.parse(String path) { + // Path template syntax: + // + // Template = "/" Segments [ Verb ] ; + // Segments = Segment { "/" Segment } ; + // Segment = "*" | "**" | LITERAL | Variable ; + // Variable = "{" FieldPath [ "=" Segments ] "}" ; + // FieldPath = IDENT { "." IDENT } ; + // Verb = ":" LITERAL ; + + String? verb; + + // Look for a trailing ":". + if (path.contains(':')) { + final index = path.indexOf(':'); + verb = path.substring(index + 1); + path = path.substring(0, index); + } + + // parse into variable and literal segments + final segments = + _findSegments(path).map((str) { + return str.startsWith('{') + ? PathVariablePathSegment( + PathVariable.parse(str.substring(1, str.length - 1)), + ) + : LiteralPathSegment(str); + }).toList(); + + return PathTemplate(segments: segments, verb: verb); + } + + static List parseRules(List rules) { + // get put post delete patch + + final result = []; + + for (final rule in rules) { + String? template; + + if (rule.hasGet()) { + template = rule.get; + } else if (rule.hasPut()) { + template = rule.put; + } else if (rule.hasPost()) { + template = rule.post; + } else if (rule.hasDelete()) { + template = rule.delete; + } else if (rule.hasPatch()) { + template = rule.patch; + } + + if (template != null) { + result.add(PathTemplate.parse(template)); + } + } + + return result; + } + + @override + String toString() => '${segments.join('/')}${verb == null ? '' : ':$verb'}'; +} + +/// Either a [LiteralPathSegment] or a [PathVariablePathSegment]. +abstract class PathSegment {} + +final class LiteralPathSegment extends PathSegment { + final String literal; + + LiteralPathSegment(this.literal); + + @override + bool operator ==(Object other) { + return other is LiteralPathSegment && other.literal == literal; + } + + @override + int get hashCode => literal.hashCode; + + @override + String toString() => literal; +} + +final class PathVariablePathSegment extends PathSegment { + final PathVariable variable; + + PathVariablePathSegment(this.variable); + + @override + bool operator ==(Object other) { + return other is PathVariablePathSegment && other.variable == variable; + } + + @override + int get hashCode => variable.hashCode; + + @override + String toString() => '{$variable}'; +} + +class PathVariable { + final List fieldPath; + final List segments; + + PathVariable({required this.fieldPath, required this.segments}); + + factory PathVariable.parse(String str) { + // name=projects/*/databases/*/documents/*/** + final index = str.indexOf('='); + + if (index == -1) { + // parse path variable shorthand + return PathVariable(fieldPath: [str], segments: ['*']); + } else { + final field = str.substring(0, index); + final segments = str.substring(index + 1); + + return PathVariable( + fieldPath: field.split('.'), + segments: segments.split('/'), + ); + } + } + + /// A dot-concanenated version of the field path. + String get name => fieldPath.join('.'); + + @override + bool operator ==(Object other) { + return other is PathVariable && + ListEquality().equals(other.fieldPath, fieldPath) && + ListEquality().equals(other.segments, segments); + } + + @override + int get hashCode => Object.hashAll(fieldPath) ^ Object.hashAll(segments); + + @override + String toString() => '${fieldPath.join('.')}=${segments.join('/')}'; +} + +/// Return a sequence of either path variables or segments. +List _findSegments(String str) { + // /v1/{parent=projects/*/databases/*/documents/*/**}/{collection_id} + // => + // v1, {parent=projects/*/databases/*/documents/*/**}, {collection_id} + + final result = []; + + while (str.isNotEmpty) { + var index = str.indexOf('{'); + + if (index == 0) { + // extract the path variable + final end = str.indexOf('}'); + result.add(str.substring(0, end + 1)); + str = str.substring(end + 1); + } else if (index == -1) { + // extract the sequence of segments + result.addAll(str.split('/').where((segment) => segment.isNotEmpty)); + str = ''; + } else { + // extract the sequence of segments + final segments = str.substring(0, index); + result.addAll(segments.split('/').where((segment) => segment.isNotEmpty)); + str = str.substring(index); + } + } + + return result; +} + +extension PathVariableExt on PathVariable { + /// Create a regex definition to match this path variable. + String createRegexMatcher() { + return segments + .map((string) { + switch (string) { + case '*': + return '[^/]*'; + case '**': + return '.*'; + default: + return string; + } + }) + .join('/'); + } + + /// Return the field path in camel-case format. + String get fieldPathCamelCase { + return fieldPath.map((str) => snakeToCamelCase(str)).join('.'); + } + + /// Generate a condition which we can use to know whether a field in a proto + /// is populated. + String protoRequestPath(String prefix) { + // Generate something like: + // + // request.hasDocument() && request.document.hasName() + + final result = []; + + for (int i = 0; i < fieldPath.length; i++) { + final path = fieldPath.sublist(0, i + 1); + + final accessor = path + .sublist(0, path.length - 1) + .map((str) => snakeToCamelCase(str)) + .join('.'); + final hazzor = '.has${snakeToCamelCase(path.last.titleCase)}()'; + + result.add('$prefix${accessor.isEmpty ? '' : '.$accessor'}$hazzor'); + } + + return result.join(' && '); + } +} + +extension StringExt on String { + String get titleCase => substring(0, 1).toUpperCase() + substring(1); +} + +/// Convert snake case to camel case (`foo_bar` => `fooBar`). +String snakeToCamelCase(String str) { + final items = str.split('_'); + return items.first + + items + .skip(1) + .map((str) => str[0].toUpperCase() + str.substring(1)) + .join(''); +} diff --git a/protoc_plugin/lib/src/grpc_generator.dart b/protoc_plugin/lib/src/grpc_generator.dart index 37852c2d..dcd946b9 100644 --- a/protoc_plugin/lib/src/grpc_generator.dart +++ b/protoc_plugin/lib/src/grpc_generator.dart @@ -151,9 +151,24 @@ class GrpcServiceGenerator { '$_clientClassname(super.channel, {super.options, super.interceptors});', ); - // generate the service call methods + final collectRegexps = {}; + + // generate the service call methods; any regexs that are referenced are + // collected in collectRegexps for (var i = 0; i < _methods.length; i++) { - _methods[i].generateClientStub(out, this, i); + _methods[i].generateClientStub(out, this, i, collectRegexps); + } + + // write out any regexps that were referenced + if (collectRegexps.isNotEmpty) { + out.println(); + + final items = collectRegexps.toList(); + for (int i = 0; i < items.length; i++) { + out.println( + "final \$core.RegExp _regexp$i = \$core.RegExp('${items[i]}');", + ); + } } // generate the method descriptors @@ -205,6 +220,8 @@ class GrpcServiceGenerator { } class _GrpcMethod { + final MethodDescriptorProto methodDescriptor; + final String _grpcName; final String _dartName; final String _serviceName; @@ -222,6 +239,7 @@ class _GrpcMethod { final bool _deprecated; _GrpcMethod._( + this.methodDescriptor, this._grpcName, this._dartName, this._serviceName, @@ -264,6 +282,7 @@ class _GrpcMethod { final deprecated = method.options.deprecated; return _GrpcMethod._( + method, grpcName, dartName, service._fullServiceName, @@ -299,6 +318,7 @@ class _GrpcMethod { IndentingWriter out, GrpcServiceGenerator serviceGenerator, int methodIndex, + Set collectRegexps, ) { out.println(); final commentBlock = serviceGenerator.fileGen.commentBlock( @@ -312,10 +332,38 @@ class _GrpcMethod { '@$coreImportPrefix.Deprecated(\'This method is deprecated\')', ); } + + final routingOption = methodDescriptor.options.routing; + final httpRules = methodDescriptor.options.httpRules; + out.addBlock( '$_clientReturnType $_dartName($_argumentType request, {${GrpcServiceGenerator._callOptions}? options,}) {', '}', () { + // Handle `routing` and `http` annotations. + // + // `routing` annotations provide explicit information about what + // 'x-goog-request-params' header to send. `http` annotations provide + // implicit information about what 'x-goog-request-params' header to + // send. + // + // `routing` annotations should be used in preference to `http` ones if + // provided. + // + // See https://google.aip.dev/client-libraries/4222 for details. + if (routingOption != null) { + if (routingOption.routingParameters.isNotEmpty) { + // TODO(devoncarew): Handle routing annotations. + out.println( + '// TODO: Parse and use routing annotation information.', + ); + } + } else if (httpRules.isNotEmpty) { + // handle an http annotation + final pathTemplates = PathTemplate.parseRules(httpRules); + _generateHttpAnnotations(out, pathTemplates, collectRegexps); + } + if (_clientStreaming && _serverStreaming) { out.println( 'return \$createStreamingCall(_\$$_dartName, request, options: options);', @@ -337,6 +385,68 @@ class _GrpcMethod { ); } + // TODO(devoncarew): This code correctly handles unary requests but does not + // generate correct code for streaming requests. For those, we need to update + // the grpc library so that we can examine the first request, and use that + // info to modify the headers that are sent. + + void _generateHttpAnnotations( + IndentingWriter out, + List pathTemplates, + Set collectRegexps, + ) { + // Build a map from a variable reference to all the matchers for it. + final variables = >{}; + for (final template in pathTemplates) { + for (final segment + in template.segments.whereType()) { + final variable = segment.variable; + variables.putIfAbsent(variable.name, () => []).add(variable); + } + } + + out.addBlock('{', '}\n', () { + out.println('final results = <\$core.String>[];'); + out.println(); + + for (final varName in variables.keys) { + final items = variables[varName]!; + final first = items.first; + + final condition = first.protoRequestPath('request'); + + out.addBlock('if ($condition) {', '}', () { + out.println('final value = request.${first.fieldPathCamelCase};'); + + out.println('final regexps = ['); + // Convert to and from a set for uniqueness. Iterate in reverse order + // as the spec calls for last matching entry wins. + final refs = []; + for (final variable in items.toSet().toList().reversed) { + final regexp = variable.createRegexMatcher(); + collectRegexps.add(regexp); + + final regexpIndex = collectRegexps.toList().indexOf(regexp); + refs.add('_regexp$regexpIndex'); + } + out.println('${refs.join(', ')}];'); + + out.println('if (regexps.any((r) => r.hasMatch(value))) {'); + out.println(" results.add('$varName=\$value');"); + out.println('}'); + }); + } + + // If necessary, merge our new call options in with any existing ones. + out.println(); + out.println('if (results.isNotEmpty) {'); + out.println(' options = \$grpc.CallOptions(metadata: {'); + out.println(" 'x-goog-request-params': results.join('&'),"); + out.println('}).mergedWith(options);'); + out.println('}'); + }); + } + void generateServiceMethodRegistration(IndentingWriter out) { out.println('\$addMethod($_serviceMethod<$_requestType, $_responseType>('); out.println(' \'$_grpcName\','); @@ -383,3 +493,20 @@ extension on ServiceOptions { String? get oauthScopes => getExtension(Client.oauthScopes) as String?; } + +extension on MethodOptions { + bool get hasHttpOption => hasExtension(Annotations.http); + List get httpRules { + if (!hasHttpOption) return []; + + final rule = getExtension(Annotations.http) as HttpRule; + + final result = [rule]; + result.addAll(rule.additionalBindings); + return result; + } + + bool get hasRountingOption => hasExtension(Routing.routing); + RoutingRule? get routing => + hasRountingOption ? getExtension(Routing.routing) : null; +} diff --git a/protoc_plugin/protos/google/api/annotations.proto b/protoc_plugin/protos/google/api/annotations.proto new file mode 100644 index 00000000..417edd8f --- /dev/null +++ b/protoc_plugin/protos/google/api/annotations.proto @@ -0,0 +1,31 @@ +// Copyright 2025 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 +// +// http://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"; + +package google.api; + +import "google/api/http.proto"; +import "google/protobuf/descriptor.proto"; + +option go_package = "google.golang.org/genproto/googleapis/api/annotations;annotations"; +option java_multiple_files = true; +option java_outer_classname = "AnnotationsProto"; +option java_package = "com.google.api"; +option objc_class_prefix = "GAPI"; + +extend google.protobuf.MethodOptions { + // See `HttpRule`. + HttpRule http = 72295728; +} diff --git a/protoc_plugin/protos/google/protobuf/compiler/plugin.proto b/protoc_plugin/protos/google/protobuf/compiler/plugin.proto index 033fab23..10d285f8 100644 --- a/protoc_plugin/protos/google/protobuf/compiler/plugin.proto +++ b/protoc_plugin/protos/google/protobuf/compiler/plugin.proto @@ -24,11 +24,11 @@ package google.protobuf.compiler; option java_package = "com.google.protobuf.compiler"; option java_outer_classname = "PluginProtos"; +import "google/protobuf/descriptor.proto"; + option csharp_namespace = "Google.Protobuf.Compiler"; option go_package = "google.golang.org/protobuf/types/pluginpb"; -import "google/protobuf/descriptor.proto"; - // The version number of protocol compiler. message Version { optional int32 major = 1; diff --git a/protoc_plugin/protos/google/protobuf/descriptor.proto b/protoc_plugin/protos/google/protobuf/descriptor.proto index cb9bea19..333b7e99 100644 --- a/protoc_plugin/protos/google/protobuf/descriptor.proto +++ b/protoc_plugin/protos/google/protobuf/descriptor.proto @@ -398,6 +398,9 @@ message ServiceDescriptorProto { repeated MethodDescriptorProto method = 2; optional ServiceOptions options = 3; + + reserved 4; + reserved "stream"; } // Describes a method of a service. @@ -753,8 +756,9 @@ message FieldOptions { // is a formalization for deprecating fields. optional bool deprecated = 3 [default = false]; + // DEPRECATED. DO NOT USE! // For Google-internal migration only. Do not use. - optional bool weak = 10 [default = false]; + optional bool weak = 10 [default = false, deprecated = true]; // Indicate that the field value should not be printed out when using debug // formats, e.g. when the field contains sensitive credentials. diff --git a/protoc_plugin/test/grpc_test.dart b/protoc_plugin/test/grpc_test.dart new file mode 100644 index 00000000..a091b2ae --- /dev/null +++ b/protoc_plugin/test/grpc_test.dart @@ -0,0 +1,244 @@ +// Copyright (c) 2025, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:protoc_plugin/src/gen/google/api/http.pb.dart'; +import 'package:protoc_plugin/src/grpc_annotations.dart'; +import 'package:test/test.dart'; + +void main() { + group('PathVariable', () { + test('parse', () { + final result = PathVariable.parse( + 'name=projects/*/databases/*/documents/*/**', + ); + expect(result.fieldPath, ['name']); + expect(result.segments, [ + 'projects', + '*', + 'databases', + '*', + 'documents', + '*', + '**', + ]); + }); + + test('parse compound field', () { + final result = PathVariable.parse('foo.bar=projects/*/databases'); + expect(result.fieldPath, ['foo', 'bar']); + expect(result.segments, ['projects', '*', 'databases']); + }); + + test('shorthand', () { + // {foo} is shorthand for {foo=*} + + final result = PathVariable.parse('foo'); + expect(result.fieldPath, ['foo']); + expect(result.segments, ['*']); + }); + }); + + group('PathVariableExt createRegexMatcher', () { + test('generation', () { + var pathVariable = PathVariable(fieldPath: ['foo'], segments: ['*']); + expect(pathVariable.createRegexMatcher(), '[^/]*'); + + pathVariable = PathVariable(fieldPath: ['foo'], segments: ['**']); + expect(pathVariable.createRegexMatcher(), '.*'); + + pathVariable = PathVariable(fieldPath: ['foo'], segments: ['foo', 'bar']); + expect(pathVariable.createRegexMatcher(), 'foo/bar'); + + pathVariable = PathVariable(fieldPath: ['foo'], segments: ['foo', '**']); + expect(pathVariable.createRegexMatcher(), 'foo/.*'); + }); + + test('regex match', () { + // .* + var pathVariable = PathVariable(fieldPath: ['foo'], segments: ['*']); + var regex = RegExp(pathVariable.createRegexMatcher()); + expect(regex.hasMatch('foo'), isTrue); + + // .*/project + pathVariable = PathVariable( + fieldPath: ['*/project'], + segments: ['*', 'project'], + ); + regex = RegExp(pathVariable.createRegexMatcher()); + expect(regex.hasMatch('foo/project'), isTrue); + + // project./.* + pathVariable = PathVariable( + fieldPath: ['project/*'], + segments: ['project', '*'], + ); + regex = RegExp(pathVariable.createRegexMatcher()); + expect(regex.hasMatch('project/foo'), isTrue); + + // project/.*/resource/.* + pathVariable = PathVariable( + fieldPath: ['project/*/resource/*'], + segments: ['project', '*', 'resource', '*'], + ); + regex = RegExp(pathVariable.createRegexMatcher()); + expect(regex.hasMatch('project/foo/resource/bar'), isTrue); + }); + }); + + group('PathTemplate', () { + test('simple', () { + final actual = PathTemplate.parse( + '/v1/{name=projects/*/databases/*/documents/*/**}', + ); + + expect(actual.segments[0], LiteralPathSegment('v1')); + expect( + actual.segments[1], + PathVariablePathSegment( + PathVariable( + fieldPath: ['name'], + segments: 'projects/*/databases/*/documents/*/**'.split('/'), + ), + ), + ); + }); + + test('two path variables', () { + final actual = PathTemplate.parse( + '/v1/{parent=projects/*/databases/*/documents/*/**}/{collection_id}', + ); + + expect(actual.segments[0], LiteralPathSegment('v1')); + expect( + actual.segments[1], + PathVariablePathSegment( + PathVariable( + fieldPath: ['parent'], + segments: 'projects/*/databases/*/documents/*/**'.split('/'), + ), + ), + ); + expect( + actual.segments[2], + PathVariablePathSegment( + PathVariable(fieldPath: ['collection_id'], segments: ['*']), + ), + ); + }); + + test('multiple literals', () { + final actual = PathTemplate.parse( + '/foo/bar/{name=projects/*/databases}/baz', + ); + + expect(actual.segments[0], LiteralPathSegment('foo')); + expect(actual.segments[1], LiteralPathSegment('bar')); + expect( + actual.segments[2], + PathVariablePathSegment( + PathVariable( + fieldPath: ['name'], + segments: 'projects/*/databases'.split('/'), + ), + ), + ); + expect(actual.segments[3], LiteralPathSegment('baz')); + }); + + test('has a verb', () { + final actual = PathTemplate.parse( + '/v1/{database=projects/*/databases/*}/documents:batchGet', + ); + + expect(actual.verb, 'batchGet'); + expect(actual.segments[0], LiteralPathSegment('v1')); + expect( + actual.segments[1], + PathVariablePathSegment( + PathVariable( + fieldPath: ['database'], + segments: 'projects/*/databases/*'.split('/'), + ), + ), + ); + expect(actual.segments[2], LiteralPathSegment('documents')); + }); + + test('parseRules', () { + // option (google.api.http).post = "{parent=projects/*}/topics"; + + final rule = HttpRule(post: '{parent=projects/*}/topics'); + final actual = PathTemplate.parseRules([rule]); + + expect(actual, hasLength(1)); + final template = actual[0]; + expect( + template.segments[0], + PathVariablePathSegment( + PathVariable(fieldPath: ['parent'], segments: ['projects', '*']), + ), + ); + expect(template.segments[1], LiteralPathSegment('topics')); + }); + + test('parseRules with additionalBindings', () { + // option (google.api.http) = { + // get: "/v1/{parent=projects/*/databases/*/documents/*/**}/{collection_id}" + // additional_bindings { + // get: "/v1/{parent=projects/*/databases/*/documents}/{collection_id}" + // } + // }; + + final rule = HttpRule( + get: '/v1/{parent=projects/*/databases/*/documents/*/**}', + additionalBindings: [ + HttpRule(get: '/v1/{parent=projects/*/databases/*/documents}'), + ], + ); + final actual = PathTemplate.parseRules([ + rule, + ...rule.additionalBindings, + ]); + + expect(actual, hasLength(2)); + + var template = actual[0]; + expect(template.segments[0], LiteralPathSegment('v1')); + expect( + template.segments[1], + PathVariablePathSegment( + PathVariable( + fieldPath: ['parent'], + segments: 'projects/*/databases/*/documents/*/**'.split('/'), + ), + ), + ); + + template = actual[1]; + expect(template.segments[0], LiteralPathSegment('v1')); + expect( + template.segments[1], + PathVariablePathSegment( + PathVariable( + fieldPath: ['parent'], + segments: 'projects/*/databases/*/documents'.split('/'), + ), + ), + ); + }); + }); + + group('StringExt', () { + test('titleCase', () { + expect('a'.titleCase, 'A'); + expect('fooBar'.titleCase, 'FooBar'); + expect('FooBar'.titleCase, 'FooBar'); + }); + }); + + test('snakeToCamelCase', () { + expect(snakeToCamelCase('foo'), 'foo'); + expect(snakeToCamelCase('foo_bar'), 'fooBar'); + }); +} diff --git a/protoc_plugin/tool/update_protos.dart b/protoc_plugin/tool/update_protos.dart index cd3f2214..c2f095da 100644 --- a/protoc_plugin/tool/update_protos.dart +++ b/protoc_plugin/tool/update_protos.dart @@ -46,6 +46,7 @@ void main(List args) async { } copy(googleapisDir, destDir, '', [ + 'google/api/annotations.proto', 'google/api/client.proto', 'google/api/http.proto', 'google/api/launch_stage.proto',