Skip to content

Commit 8c191a5

Browse files
authored
feat: adds dynamic routing. (googleapis#1135)
feat: adds dynamic routing files.
1 parent 5df9733 commit 8c191a5

File tree

17 files changed

+1205
-107
lines changed

17 files changed

+1205
-107
lines changed

gapic/schema/wrappers.py

Lines changed: 130 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,13 @@
3333
import re
3434
from itertools import chain
3535
from typing import (Any, cast, Dict, FrozenSet, Iterator, Iterable, List, Mapping,
36-
ClassVar, Optional, Sequence, Set, Tuple, Union)
36+
ClassVar, Optional, Sequence, Set, Tuple, Union, Pattern)
3737
from google.api import annotations_pb2 # type: ignore
3838
from google.api import client_pb2
3939
from google.api import field_behavior_pb2
4040
from google.api import http_pb2
4141
from google.api import resource_pb2
42+
from google.api import routing_pb2
4243
from google.api_core import exceptions
4344
from google.api_core import path_template
4445
from google.cloud import extended_operations_pb2 as ex_ops_pb2 # type: ignore
@@ -47,6 +48,7 @@
4748

4849
from gapic import utils
4950
from gapic.schema import metadata
51+
from gapic.utils import uri_sample
5052

5153

5254
@dataclasses.dataclass(frozen=True)
@@ -763,6 +765,118 @@ class RetryInfo:
763765
retryable_exceptions: FrozenSet[exceptions.GoogleAPICallError]
764766

765767

768+
@dataclasses.dataclass(frozen=True)
769+
class RoutingParameter:
770+
field: str
771+
path_template: str
772+
773+
def _split_into_segments(self, path_template):
774+
segments = path_template.split("/")
775+
named_segment_ids = [i for i, x in enumerate(
776+
segments) if "{" in x or "}" in x]
777+
# bar/{foo}/baz, bar/{foo=one/two/three}/baz.
778+
assert len(named_segment_ids) <= 2
779+
if len(named_segment_ids) == 2:
780+
# Need to merge a named segment.
781+
i, j = named_segment_ids
782+
segments = (
783+
segments[:i] +
784+
[self._merge_segments(segments[i: j + 1])] + segments[j + 1:]
785+
)
786+
return segments
787+
788+
def _convert_segment_to_regex(self, segment):
789+
# Named segment
790+
if "{" in segment:
791+
assert "}" in segment
792+
# Strip "{" and "}"
793+
segment = segment[1:-1]
794+
if "=" not in segment:
795+
# e.g. {foo} should be {foo=*}
796+
return self._convert_segment_to_regex("{" + f"{segment}=*" + "}")
797+
key, sub_path_template = segment.split("=")
798+
group_name = f"?P<{key}>"
799+
sub_regex = self._convert_to_regex(sub_path_template)
800+
return f"({group_name}{sub_regex})"
801+
# Wildcards
802+
if "**" in segment:
803+
# ?: nameless capture
804+
return ".*"
805+
if "*" in segment:
806+
return "[^/]+"
807+
# Otherwise it's collection ID segment: transformed identically.
808+
return segment
809+
810+
def _merge_segments(self, segments):
811+
acc = segments[0]
812+
for x in segments[1:]:
813+
# Don't add "/" if it's followed by a "**"
814+
# because "**" will eat it.
815+
if x == ".*":
816+
acc += "(?:/.*)?"
817+
else:
818+
acc += "/"
819+
acc += x
820+
return acc
821+
822+
def _how_many_named_segments(self, path_template):
823+
return path_template.count("{")
824+
825+
def _convert_to_regex(self, path_template):
826+
if self._how_many_named_segments(path_template) > 1:
827+
# This also takes care of complex patterns (i.e. {foo}~{bar})
828+
raise ValueError("There must be exactly one named segment. {} has {}.".format(
829+
path_template, self._how_many_named_segments(path_template)))
830+
segments = self._split_into_segments(path_template)
831+
segment_regexes = [self._convert_segment_to_regex(x) for x in segments]
832+
final_regex = self._merge_segments(segment_regexes)
833+
return final_regex
834+
835+
def _to_regex(self, path_template: str) -> Pattern:
836+
"""Converts path_template into a Python regular expression string.
837+
Args:
838+
path_template (str): A path template corresponding to a resource name.
839+
It can only have 0 or 1 named segments. It can not contain complex resource ID path segments.
840+
See https://google.aip.dev/122, https://google.aip.dev/4222
841+
and https://google.aip.dev/client-libraries/4231 for more details.
842+
Returns:
843+
Pattern: A Pattern object that matches strings conforming to the path_template.
844+
"""
845+
return re.compile(f"^{self._convert_to_regex(path_template)}$")
846+
847+
def to_regex(self) -> Pattern:
848+
return self._to_regex(self.path_template)
849+
850+
@property
851+
def key(self) -> Union[str, None]:
852+
if self.path_template == "":
853+
return self.field
854+
regex = self.to_regex()
855+
group_names = list(regex.groupindex)
856+
# Only 1 named segment is allowed and so only 1 key.
857+
return group_names[0] if group_names else self.field
858+
859+
@property
860+
def sample_request(self) -> str:
861+
"""return json dict for sample request matching the uri template."""
862+
sample = uri_sample.sample_from_path_template(
863+
self.field, self.path_template)
864+
return json.dumps(sample)
865+
866+
867+
@dataclasses.dataclass(frozen=True)
868+
class RoutingRule:
869+
routing_parameters: List[RoutingParameter]
870+
871+
@classmethod
872+
def try_parse_routing_rule(cls, routing_rule: routing_pb2.RoutingRule) -> Optional['RoutingRule']:
873+
params = getattr(routing_rule, 'routing_parameters')
874+
if not params:
875+
return None
876+
params = [RoutingParameter(x.field, x.path_template) for x in params]
877+
return cls(params)
878+
879+
766880
@dataclasses.dataclass(frozen=True)
767881
class HttpRule:
768882
"""Representation of the method's http bindings."""
@@ -788,59 +902,18 @@ def sample_from_path_fields(paths: List[Tuple[Field, str, str]]) -> Dict[str, An
788902
Returns:
789903
A new nested dict with the templates instantiated.
790904
"""
791-
792905
request: Dict[str, Any] = {}
793906

794-
def _sample_names() -> Iterator[str]:
795-
sample_num: int = 0
796-
while True:
797-
sample_num += 1
798-
yield "sample{}".format(sample_num)
799-
800-
def add_field(obj, path, value):
801-
"""Insert a field into a nested dict and return the (outer) dict.
802-
Keys and sub-dicts are inserted if necessary to create the path.
803-
e.g. if obj, as passed in, is {}, path is "a.b.c", and value is
804-
"hello", obj will be updated to:
805-
{'a':
806-
{'b':
807-
{
808-
'c': 'hello'
809-
}
810-
}
811-
}
812-
813-
Args:
814-
obj: a (possibly) nested dict (parsed json)
815-
path: a segmented field name, e.g. "a.b.c"
816-
where each part is a dict key.
817-
value: the value of the new key.
818-
Returns:
819-
obj, possibly modified
820-
Raises:
821-
AttributeError if the path references a key that is
822-
not a dict.: e.g. path='a.b', obj = {'a':'abc'}
823-
"""
824-
825-
segments = path.split('.')
826-
leaf = segments.pop()
827-
subfield = obj
828-
for segment in segments:
829-
subfield = subfield.setdefault(segment, {})
830-
subfield[leaf] = value
831-
return obj
832-
833-
sample_names = _sample_names()
907+
sample_names_ = uri_sample.sample_names()
834908
for field, path, template in paths:
835909
sample_value = re.sub(
836910
r"(\*\*|\*)",
837-
lambda n: next(sample_names),
911+
lambda n: next(sample_names_),
838912
template or '*'
839913
) if field.type == PrimitiveType.build(str) else field.mock_value_original_type
840-
add_field(request, path, sample_value)
914+
uri_sample.add_field(request, path, sample_value)
841915

842916
return request
843-
844917
sample = sample_from_path_fields(self.path_fields(method))
845918
return sample
846919

@@ -982,6 +1055,18 @@ def field_headers(self) -> Sequence[str]:
9821055

9831056
return next((tuple(pattern.findall(verb)) for verb in potential_verbs if verb), ())
9841057

1058+
@property
1059+
def explicit_routing(self):
1060+
return routing_pb2.routing in self.options.Extensions
1061+
1062+
@property
1063+
def routing_rule(self):
1064+
if self.explicit_routing:
1065+
routing_ext = self.options.Extensions[routing_pb2.routing]
1066+
routing_rule = RoutingRule.try_parse_routing_rule(routing_ext)
1067+
return routing_rule
1068+
return None
1069+
9851070
@property
9861071
def http_options(self) -> List[HttpRule]:
9871072
"""Return a list of the http bindings for this method."""

gapic/templates/%namespace/%name_%version/%sub/services/%service/client.py.j2

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -478,8 +478,31 @@ class {{ service.client_name }}(metaclass={{ service.client_name }}Meta):
478478
# Wrap the RPC method; this adds retry and timeout information,
479479
# and friendly error handling.
480480
rpc = self._transport._wrapped_methods[self._transport.{{ method.name|snake_case}}]
481-
{% if method.field_headers %}
482481

482+
{% if method.explicit_routing %}
483+
header_params = {}
484+
{% for routing_param in method.routing_rule.routing_parameters %}
485+
{% if routing_param.path_template %} {# Need to match. #}
486+
487+
routing_param_regex = {{ routing_param.to_regex() }}
488+
regex_match = routing_param_regex.match(request.{{ routing_param.field }})
489+
if regex_match and regex_match.group("{{ routing_param.key }}"):
490+
header_params["{{ routing_param.key }}"] = regex_match.group("{{ routing_param.key }}")
491+
492+
{% else %}
493+
494+
if request.{{ routing_param.field }}:
495+
header_params["{{ routing_param.key }}"] = request.{{ routing_param.field }}
496+
497+
{% endif %}
498+
{% endfor %} {# method.routing_rule.routing_parameters #}
499+
500+
if header_params:
501+
metadata = tuple(metadata) + (
502+
gapic_v1.routing_header.to_grpc_metadata(header_params),
503+
)
504+
505+
{% elif method.field_headers %} {# implicit routing #}
483506
# Certain fields should be provided within the metadata header;
484507
# add these here.
485508
metadata = tuple(metadata) + (
@@ -491,7 +514,7 @@ class {{ service.client_name }}(metaclass={{ service.client_name }}Meta):
491514
{% endfor %}
492515
)),
493516
)
494-
{% endif %}
517+
{% endif %} {# method.explicit_routing #}
495518

496519
# Send the request.
497520
{%+ if not method.void %}response = {% endif %}rpc(

gapic/templates/tests/unit/gapic/%name_%version/%sub/test_%service.py.j2

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -756,7 +756,45 @@ async def test_{{ method_name }}_async_from_dict():
756756
await test_{{ method_name }}_async(request_type=dict)
757757

758758

759-
{% if method.field_headers and not method.client_streaming %}
759+
{% if method.explicit_routing %}
760+
def test_{{ method.name|snake_case }}_routing_parameters():
761+
client = {{ service.client_name }}(
762+
credentials=ga_credentials.AnonymousCredentials(),
763+
)
764+
765+
{% for routing_param in method.routing_rule.routing_parameters %}
766+
# Any value that is part of the HTTP/1.1 URI should be sent as
767+
# a field header. Set these to a non-empty value.
768+
request = {{ method.input.ident }}({{ routing_param.sample_request }})
769+
770+
# Mock the actual call within the gRPC stub, and fake the request.
771+
with mock.patch.object(
772+
type(client.transport.{{ method.name|snake_case }}),
773+
'__call__') as call:
774+
{% if method.void %}
775+
call.return_value = None
776+
{% elif method.lro %}
777+
call.return_value = operations_pb2.Operation(name='operations/op')
778+
{% elif method.server_streaming %}
779+
call.return_value = iter([{{ method.output.ident }}()])
780+
{% else %}
781+
call.return_value = {{ method.output.ident }}()
782+
{% endif %}
783+
client.{{ method.name|snake_case }}(request)
784+
785+
# Establish that the underlying gRPC stub method was called.
786+
assert len(call.mock_calls) == 1
787+
_, args, _ = call.mock_calls[0]
788+
assert args[0] == request
789+
790+
_, _, kw = call.mock_calls[0]
791+
# This test doesn't assert anything useful.
792+
assert kw['metadata']
793+
{% endfor %}
794+
{% endif %}
795+
796+
797+
{% if method.field_headers and not method.client_streaming and not method.explicit_routing %}
760798
def test_{{ method_name }}_field_headers():
761799
client = {{ service.client_name }}(
762800
credentials=ga_credentials.AnonymousCredentials(),

0 commit comments

Comments
 (0)