Skip to content

Commit e89fd23

Browse files
kbandesKenneth Bandesparthea
authored
feat: add support for long-running operations with rest transport. (#1094)
* feat: add support for long-running operations with rest transport. * Update gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/rest.py.j2 Co-authored-by: Anthonios Partheniou <[email protected]> * Update gapic/templates/%namespace/%name_%version/%sub/services/%service/transports/rest.py.j2 Co-authored-by: Anthonios Partheniou <[email protected]> * fix: address review comments * fix: rename rest operations client, fix rest lro unit tests * fix: removed extra space in assignment * fix: update goldens for integration tests due to template changes. Co-authored-by: Kenneth Bandes <[email protected]> Co-authored-by: Anthonios Partheniou <[email protected]>
1 parent 3d00ba4 commit e89fd23

File tree

10 files changed

+178
-36
lines changed

10 files changed

+178
-36
lines changed

gapic/schema/api.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,14 @@
2727
from types import MappingProxyType
2828

2929
from google.api_core import exceptions
30+
from google.api import http_pb2 # type: ignore
3031
from google.api import resource_pb2 # type: ignore
32+
from google.api import service_pb2 # type: ignore
3133
from google.gapic.metadata import gapic_metadata_pb2 # type: ignore
3234
from google.longrunning import operations_pb2 # type: ignore
3335
from google.protobuf import descriptor_pb2
3436
from google.protobuf.json_format import MessageToJson
37+
from google.protobuf.json_format import ParseDict
3538

3639
import grpc # type: ignore
3740

@@ -226,6 +229,7 @@ class API:
226229
"""
227230
naming: api_naming.Naming
228231
all_protos: Mapping[str, Proto]
232+
service_yaml_config: service_pb2.Service
229233
subpackage_view: Tuple[str, ...] = dataclasses.field(default_factory=tuple)
230234

231235
@classmethod
@@ -318,8 +322,14 @@ def disambiguate_keyword_fname(
318322
for name, proto in pre_protos.items()
319323
}
320324

325+
# Parse the google.api.Service proto from the service_yaml data.
326+
service_yaml_config = service_pb2.Service()
327+
ParseDict(opts.service_yaml_config, service_yaml_config)
328+
321329
# Done; return the API.
322-
return cls(naming=naming, all_protos=protos)
330+
return cls(naming=naming,
331+
all_protos=protos,
332+
service_yaml_config=service_yaml_config)
323333

324334
@cached_property
325335
def enums(self) -> Mapping[str, wrappers.EnumType]:
@@ -374,6 +384,24 @@ def services(self) -> Mapping[str, wrappers.Service]:
374384
*[p.services for p in self.protos.values()],
375385
)
376386

387+
@cached_property
388+
def http_options(self) -> Mapping[str, Sequence[wrappers.HttpRule]]:
389+
"""Return a map of API-wide http rules."""
390+
391+
def make_http_options(rule: http_pb2.HttpRule
392+
) -> Sequence[wrappers.HttpRule]:
393+
http_options = [rule] + list(rule.additional_bindings)
394+
opt_gen = (wrappers.HttpRule.try_parse_http_rule(http_rule)
395+
for http_rule in http_options)
396+
return [rule for rule in opt_gen if rule]
397+
398+
result: Mapping[str, Sequence[http_pb2.HttpRule]] = {
399+
rule.selector: make_http_options(rule)
400+
for rule in self.service_yaml_config.http.rules
401+
}
402+
403+
return result
404+
377405
@cached_property
378406
def subpackages(self) -> Mapping[str, 'API']:
379407
"""Return a map of all subpackages, if any.

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

Lines changed: 33 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ from google.api_core import path_template
1010
from google.api_core import gapic_v1
1111
{% if service.has_lro %}
1212
from google.api_core import operations_v1
13+
from google.protobuf import json_format
1314
{% endif %}
1415
from requests import __version__ as requests_version
1516
from typing import Callable, Dict, Optional, Sequence, Tuple, Union
@@ -25,10 +26,6 @@ except AttributeError: # pragma: NO COVER
2526
{% block content %}
2627

2728

28-
{% if service.has_lro %}
29-
{% endif %}
30-
31-
3229
{# TODO(yon-mg): re-add python_import/ python_modules from removed diff/current grpc template code #}
3330
{% filter sort_lines %}
3431
{% for method in service.methods.values() %}
@@ -134,31 +131,41 @@ class {{service.name}}RestTransport({{service.name}}Transport):
134131
This property caches on the instance; repeated calls return the same
135132
client.
136133
"""
137-
# Sanity check: Only create a new client if we do not already have one.
134+
# Only create a new client if we do not already have one.
138135
if self._operations_client is None:
139-
from google.api_core import grpc_helpers
140-
141-
self._operations_client = operations_v1.OperationsClient(
142-
grpc_helpers.create_channel(
143-
self._host,
136+
http_options = {
137+
{% for selector, rules in api.http_options.items() %}
138+
{% if selector.startswith('google.longrunning.Operations') %}
139+
'{{ selector }}': [
140+
{% for rule in rules %}
141+
{
142+
'method': '{{ rule.method }}',
143+
'uri': '{{ rule.uri }}',
144+
{% if rule.body %}
145+
'body': '{{ rule.body }}',
146+
{% endif %}
147+
},
148+
{% endfor %}{# rules #}
149+
],
150+
{% endif %}{# longrunning.Operations #}
151+
{% endfor %}{# http_options #}
152+
}
153+
154+
rest_transport = operations_v1.OperationsRestTransport(
155+
host=self._host,
144156
credentials=self._credentials,
145-
default_scopes=cls.AUTH_SCOPES,
146157
scopes=self._scopes,
147-
default_host=cls.DEFAULT_HOST,
148-
options=[
149-
("grpc.max_send_message_length", -1),
150-
("grpc.max_receive_message_length", -1),
151-
],
152-
)
153-
)
158+
http_options=http_options)
159+
160+
self._operations_client = operations_v1.AbstractOperationsClient(transport=rest_transport)
154161

155162
# Return the client from cache.
156163
return self._operations_client
157164

158165

159-
{% endif %}
166+
{% endif %}{# service.has_lro #}
160167
{% for method in service.methods.values() %}
161-
{%- if method.http_options and not method.lro and not (method.server_streaming or method.client_streaming) %}
168+
{%- if method.http_options and not (method.server_streaming or method.client_streaming) %}
162169
def _{{method.name | snake_case}}(self,
163170
request: {{method.input.ident}}, *,
164171
retry: OptionalRetry=gapic_v1.method.DEFAULT,
@@ -279,11 +286,17 @@ class {{service.name}}RestTransport({{service.name}}Transport):
279286
{% if not method.void %}
280287

281288
# Return the response
289+
{% if method.lro %}
290+
return_op = operations_pb2.Operation()
291+
json_format.Parse(response.content, return_op, ignore_unknown_fields=True)
292+
return return_op
293+
{% else %}
282294
return {{method.output.ident}}.from_json(
283295
response.content,
284296
ignore_unknown_fields=True
285297
)
286298
{% endif %}
299+
{% endif %}
287300
{% else %}
288301

289302
def _{{method.name | snake_case}}(self,
@@ -296,10 +309,6 @@ class {{service.name}}RestTransport({{service.name}}Transport):
296309

297310
raise RuntimeError(
298311
"Cannot define a method without a valid 'google.api.http' annotation.")
299-
{%- elif method.lro %}
300-
301-
raise NotImplementedError(
302-
"LRO over REST is not yet defined for python client.")
303312
{%- elif method.server_streaming or method.client_streaming %}
304313

305314
raise NotImplementedError(

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

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,10 @@ from google.api_core import grpc_helpers_async
3636
from google.api_core import path_template
3737
{% if service.has_lro %}
3838
from google.api_core import future
39+
from google.api_core import operation
3940
from google.api_core import operations_v1
4041
from google.longrunning import operations_pb2
42+
from google.protobuf import json_format
4143
{% endif %}
4244
from google.api_core import gapic_v1
4345
{% for method in service.methods.values() %}
@@ -1119,8 +1121,8 @@ def test_{{ method_name }}_raw_page_lro():
11191121

11201122
{% for method in service.methods.values() if 'rest' in opts.transport and
11211123
method.http_options %}{% with method_name = method.name|snake_case + "_unary" if method.operation_service else method.name|snake_case %}
1122-
{# TODO(kbandes): remove this if condition when lro and streaming are supported. #}
1123-
{% if not method.lro and not (method.server_streaming or method.client_streaming) %}
1124+
{# TODO(kbandes): remove this if condition when streaming is supported in rest. #}
1125+
{% if not (method.server_streaming or method.client_streaming) %}
11241126
def test_{{ method_name }}_rest(transport: str = 'rest', request_type={{ method.input.ident }}):
11251127
client = {{ service.client_name }}(
11261128
credentials=ga_credentials.AnonymousCredentials(),
@@ -1167,11 +1169,13 @@ def test_{{ method_name }}_rest(transport: str = 'rest', request_type={{ method.
11671169
# Wrap the value into a proper Response obj
11681170
response_value = Response()
11691171
response_value.status_code = 200
1170-
{% if method.void %}
1172+
{% if method.void %}
11711173
json_return_value = ''
1172-
{% else %}
1174+
{% elif method.lro %}
1175+
json_return_value = json_format.MessageToJson(return_value)
1176+
{% else %}
11731177
json_return_value = {{ method.output.ident }}.to_json(return_value)
1174-
{% endif %}
1178+
{% endif %}
11751179
response_value._content = json_return_value.encode('UTF-8')
11761180
req.return_value = response_value
11771181
{% if method.client_streaming %}
@@ -1188,6 +1192,8 @@ def test_{{ method_name }}_rest(transport: str = 'rest', request_type={{ method.
11881192
# Establish that the response is the type that we expect.
11891193
{% if method.void %}
11901194
assert response is None
1195+
{% elif method.lro %}
1196+
assert response.operation.name == "operations/spam"
11911197
{% else %}
11921198
assert isinstance(response, {{ method.client_output.ident }})
11931199
{% for field in method.output.fields.values() | rejectattr('message') %}
@@ -1264,11 +1270,13 @@ def test_{{ method_name }}_rest_flattened(transport: str = 'rest'):
12641270
# Wrap the value into a proper Response obj
12651271
response_value = Response()
12661272
response_value.status_code = 200
1267-
{% if method.void %}
1273+
{% if method.void %}
12681274
json_return_value = ''
1269-
{% else %}
1275+
{% elif method.lro %}
1276+
json_return_value = json_format.MessageToJson(return_value)
1277+
{% else %}
12701278
json_return_value = {{ method.output.ident }}.to_json(return_value)
1271-
{% endif %}
1279+
{% endif %}
12721280

12731281
response_value._content = json_return_value.encode('UTF-8')
12741282
req.return_value = response_value
@@ -1453,6 +1461,7 @@ def test_{{ method_name }}_rest_error():
14531461
client.{{ method_name }}({})
14541462

14551463
{%- endif %}
1464+
14561465
{% endif %}{% endwith %}{# method_name #}
14571466

14581467
{% endfor -%} {#- method in methods for rest #}

gapic/utils/options.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import json
2121
import os
2222
import warnings
23+
import yaml
2324

2425
from gapic.samplegen_utils import utils as samplegen_utils
2526

@@ -45,6 +46,8 @@ class Options:
4546
metadata: bool = False
4647
# TODO(yon-mg): should there be an enum for transport type?
4748
transport: List[str] = dataclasses.field(default_factory=lambda: [])
49+
service_yaml_config: Dict[str, Any] = dataclasses.field(
50+
default_factory=dict)
4851

4952
# Class constants
5053
PYTHON_GAPIC_PREFIX: str = 'python-gapic-'
@@ -54,6 +57,7 @@ class Options:
5457
'metadata', # generate GAPIC metadata JSON file
5558
'old-naming', # TODO(dovs): Come up with a better comment
5659
'retry-config', # takes a path
60+
'service-yaml', # takes a path
5761
'samples', # output dir
5862
'autogen-snippets', # produce auto-generated snippets
5963
# transport type(s) delineated by '+' (i.e. grpc, rest, custom.[something], etc?)
@@ -129,6 +133,16 @@ def tweak_path(p):
129133
with open(retry_paths[-1]) as f:
130134
retry_cfg = json.load(f)
131135

136+
service_yaml_config = {}
137+
service_yaml_paths = opts.pop('service-yaml', None)
138+
if service_yaml_paths:
139+
# Just use the last file specified.
140+
with open(service_yaml_paths[-1]) as f:
141+
service_yaml_config = yaml.load(f, Loader=yaml.Loader)
142+
# The yaml service files typically have this field,
143+
# but it is not a field in the gogle.api.Service proto.
144+
service_yaml_config.pop('type', None)
145+
132146
# Build the options instance.
133147
sample_paths = opts.pop('samples', [])
134148

@@ -150,7 +164,8 @@ def tweak_path(p):
150164
add_iam_methods=bool(opts.pop('add-iam-methods', False)),
151165
metadata=bool(opts.pop('metadata', False)),
152166
# transport should include desired transports delimited by '+', e.g. transport='grpc+rest'
153-
transport=opts.pop('transport', ['grpc'])[0].split('+')
167+
transport=opts.pop('transport', ['grpc'])[0].split('+'),
168+
service_yaml_config=service_yaml_config,
154169
)
155170

156171
# Note: if we ever need to recursively check directories for sample

rules_python_gapic/py_gapic.bzl

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ def py_gapic_library(
2121
plugin_args = None,
2222
opt_args = None,
2323
metadata = True,
24+
service_yaml = None,
2425
**kwargs):
2526
# srcjar_target_name = "%s_srcjar" % name
2627
srcjar_target_name = name
@@ -35,6 +36,8 @@ def py_gapic_library(
3536
file_args = {}
3637
if grpc_service_config:
3738
file_args[grpc_service_config] = "retry-config"
39+
if service_yaml:
40+
file_args[service_yaml] = "service-yaml"
3841

3942
proto_custom_library(
4043
name = srcjar_target_name,

tests/integration/goldens/asset/tests/unit/gapic/asset_v1/test_asset_service.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from google.api_core import gapic_v1
3030
from google.api_core import grpc_helpers
3131
from google.api_core import grpc_helpers_async
32+
from google.api_core import operation
3233
from google.api_core import operation_async # type: ignore
3334
from google.api_core import operations_v1
3435
from google.api_core import path_template
@@ -44,6 +45,7 @@
4445
from google.oauth2 import service_account
4546
from google.protobuf import duration_pb2 # type: ignore
4647
from google.protobuf import field_mask_pb2 # type: ignore
48+
from google.protobuf import json_format
4749
from google.protobuf import timestamp_pb2 # type: ignore
4850
from google.type import expr_pb2 # type: ignore
4951
import google.auth

tests/integration/goldens/redis/tests/unit/gapic/redis_v1/test_cloud_redis.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from google.api_core import gapic_v1
3030
from google.api_core import grpc_helpers
3131
from google.api_core import grpc_helpers_async
32+
from google.api_core import operation
3233
from google.api_core import operation_async # type: ignore
3334
from google.api_core import operations_v1
3435
from google.api_core import path_template
@@ -42,6 +43,7 @@
4243
from google.longrunning import operations_pb2
4344
from google.oauth2 import service_account
4445
from google.protobuf import field_mask_pb2 # type: ignore
46+
from google.protobuf import json_format
4547
from google.protobuf import timestamp_pb2 # type: ignore
4648
import google.auth
4749

tests/unit/generator/test_generator.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import jinja2
2020
import pytest
2121

22+
from google.api import service_pb2
2223
from google.protobuf import descriptor_pb2
2324
from google.protobuf.compiler.plugin_pb2 import CodeGeneratorResponse
2425

@@ -767,9 +768,17 @@ def make_proto(
767768
).proto
768769

769770

770-
def make_api(*protos, naming: naming.Naming = None, **kwargs) -> api.API:
771+
def make_api(
772+
*protos,
773+
naming: naming.Naming = None,
774+
service_yaml_config: service_pb2.Service = None,
775+
**kwargs
776+
) -> api.API:
771777
return api.API(
772-
naming=naming or make_naming(), all_protos={i.name: i for i in protos}, **kwargs
778+
naming=naming or make_naming(),
779+
service_yaml_config=service_yaml_config or service_pb2.Service(),
780+
all_protos={i.name: i for i in protos},
781+
**kwargs
773782
)
774783

775784

0 commit comments

Comments
 (0)