Skip to content
This repository was archived by the owner on Mar 26, 2026. It is now read-only.

Commit 0e38fa8

Browse files
Aza Tulepbergenovparthea
andauthored
feat: Implement REST support for MixIns (#1378)
Implements MixIns support in REST. The implementation is different than what was used for GRPC version. Special wrappers for MixIns are created to make the code less repetitive. Co-authored-by: Anthonios Partheniou <partheniou@google.com>
1 parent 285ed93 commit 0e38fa8

9 files changed

Lines changed: 477 additions & 1 deletion

File tree

gapic/schema/api.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
from google.protobuf.descriptor_pb2 import MethodDescriptorProto
4444
from google.api import annotations_pb2 # type: ignore
4545
from gapic.schema import metadata
46+
from gapic.schema import mixins
4647
from gapic.schema import wrappers
4748
from gapic.schema import naming as api_naming
4849
from gapic.utils import cached_property
@@ -515,6 +516,16 @@ def get_custom_operation_service(self, method: "wrappers.Method") -> "wrappers.S
515516

516517
return op_serv
517518

519+
@cached_property
520+
def mixin_api_signatures(self):
521+
"""Compile useful info about MixIn API signatures.
522+
523+
Returns:
524+
Mapping[str, wrappers.MixinMethod]: Useful info
525+
about MixIn methods present for the main API.
526+
"""
527+
return {name: mixins.MIXINS_MAP[name] for name in self.mixin_api_methods}
528+
518529
@cached_property
519530
def mixin_api_methods(self) -> Dict[str, MethodDescriptorProto]:
520531
methods: Dict[str, MethodDescriptorProto] = {}
@@ -529,6 +540,20 @@ def mixin_api_methods(self) -> Dict[str, MethodDescriptorProto]:
529540
self._get_methods_from_service(operations_pb2)}
530541
return methods
531542

543+
@cached_property
544+
def mixin_http_options(self):
545+
"""Gather HTTP options for the MixIn methods."""
546+
api_methods = self.mixin_api_methods
547+
res = {}
548+
for s in api_methods:
549+
m = api_methods[s]
550+
http = m.options.Extensions[annotations_pb2.http]
551+
http_options = [http] + list(http.additional_bindings)
552+
opt_gen = (wrappers.MixinHttpRule.try_parse_http_rule(http_rule)
553+
for http_rule in http_options)
554+
res[s] = [rule for rule in opt_gen if rule]
555+
return res
556+
532557
@cached_property
533558
def has_location_mixin(self) -> bool:
534559
return len(list(filter(lambda api: api.name == "google.cloud.location.Locations", self.service_yaml_config.apis))) > 0

gapic/schema/mixins.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# Copyright 2022 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# https://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from gapic.schema import wrappers
16+
17+
MIXINS_MAP = {
18+
'DeleteOperation': wrappers.MixinMethod(
19+
'DeleteOperation',
20+
request_type='operations_pb2.DeleteOperationRequest',
21+
response_type='None'
22+
),
23+
'WaitOperation': wrappers.MixinMethod(
24+
'WaitOperation',
25+
request_type='operations_pb2.WaitOperationRequest',
26+
response_type='operations_pb2.Operation'
27+
),
28+
'ListOperations': wrappers.MixinMethod(
29+
'ListOperations',
30+
request_type='operations_pb2.ListOperationsRequest',
31+
response_type='operations_pb2.ListOperationsResponse'
32+
),
33+
'CancelOperation': wrappers.MixinMethod(
34+
'CancelOperation',
35+
request_type='operations_pb2.CancelOperationRequest',
36+
response_type='None'
37+
),
38+
'GetOperation': wrappers.MixinMethod(
39+
'GetOperation',
40+
request_type='operations_pb2.GetOperationRequest',
41+
response_type='operations_pb2.Operation'
42+
),
43+
'TestIamPermissions': wrappers.MixinMethod(
44+
'TestIamPermissions',
45+
request_type='iam_policy_pb2.TestIamPermissionsRequest',
46+
response_type='iam_policy_pb2.TestIamPermissionsResponse'
47+
),
48+
'GetIamPolicy': wrappers.MixinMethod(
49+
'GetIamPolicy',
50+
request_type='iam_policy_pb2.GetIamPolicyRequest',
51+
response_type='policy_pb2.Policy'
52+
),
53+
'SetIamPolicy': wrappers.MixinMethod(
54+
'SetIamPolicy',
55+
request_type='iam_policy_pb2.SetIamPolicyRequest',
56+
response_type='policy_pb2.Policy'
57+
),
58+
'ListLocations': wrappers.MixinMethod(
59+
'ListLocations',
60+
request_type='locations_pb2.ListLocationsRequest',
61+
response_type='locations_pb2.ListLocationsResponse'
62+
),
63+
'GetLocation': wrappers.MixinMethod(
64+
'GetLocation',
65+
request_type='locations_pb2.GetLocationRequest',
66+
response_type='locations_pb2.Location'
67+
)
68+
}

gapic/schema/wrappers.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1094,6 +1094,32 @@ def try_parse_http_rule(cls, http_rule) -> Optional['HttpRule']:
10941094
return cls(method, uri, body)
10951095

10961096

1097+
@dataclasses.dataclass(frozen=True)
1098+
class MixinMethod:
1099+
name: str
1100+
request_type: str
1101+
response_type: str
1102+
1103+
1104+
@dataclasses.dataclass(frozen=True)
1105+
class MixinHttpRule(HttpRule):
1106+
def path_fields(self, uri):
1107+
"""return list of (name, template) tuples extracted from uri."""
1108+
return [
1109+
(match.group("name"), match.group("template"))
1110+
for match in path_template._VARIABLE_RE.finditer(uri)
1111+
if match.group("name")
1112+
]
1113+
1114+
@property
1115+
def sample_request(self):
1116+
req = uri_sample.sample_from_path_fields(self.path_fields(self.uri))
1117+
if not self.body or self.body == "" or self.body == "*":
1118+
return req
1119+
req[self.body] = {} # just an empty json.
1120+
return req
1121+
1122+
10971123
@dataclasses.dataclass(frozen=True)
10981124
class Method:
10991125
"""Description of a method (defined with the ``rpc`` keyword)."""
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
{% if "rest" in opts.transport %}
2+
3+
{% for name, sig in api.mixin_api_signatures.items() %}
4+
@property
5+
def {{ name|snake_case }}(self):
6+
return self._{{ name }}(self._session, self._host, self._interceptor) # type: ignore
7+
8+
class _{{ name }}({{service.name}}RestStub):
9+
def __call__(self,
10+
request: {{ sig.request_type }}, *,
11+
retry: OptionalRetry=gapic_v1.method.DEFAULT,
12+
timeout: float=None,
13+
metadata: Sequence[Tuple[str, str]]=(),
14+
) -> {{ sig.response_type }}:
15+
16+
r"""Call the {{- ' ' -}}
17+
{{ (name|snake_case).replace('_',' ')|wrap(
18+
width=70, offset=45, indent=8) }}
19+
{{- ' ' -}} method over HTTP.
20+
21+
Args:
22+
request ({{ sig.request_type }}):
23+
The request object for {{ name }} method.
24+
retry (google.api_core.retry.Retry): Designation of what errors, if any,
25+
should be retried.
26+
timeout (float): The timeout for this request.
27+
metadata (Sequence[Tuple[str, str]]): Strings which should be
28+
sent along with the request as metadata.
29+
{% if sig.response_type != 'None' %}
30+
31+
Returns:
32+
{{ sig.response_type }}: Response from {{ name }} method.
33+
{% endif %}
34+
"""
35+
36+
http_options: List[Dict[str, str]] = [
37+
{%- for rule in api.mixin_http_options["{}".format(name)] %}{
38+
'method': '{{ rule.method }}',
39+
'uri': '{{ rule.uri }}',
40+
{% if rule.body %}
41+
'body': '{{ rule.body }}',
42+
{% endif %}{# rule.body #}
43+
},
44+
{% endfor %}
45+
]
46+
47+
request, metadata = self._interceptor.pre_{{ name|snake_case }}(request, metadata)
48+
request_kwargs = json_format.MessageToDict(request)
49+
transcoded_request = path_template.transcode(
50+
http_options, **request_kwargs)
51+
52+
{% set body_spec = api.mixin_http_options["{}".format(name)][0].body %}
53+
{%- if body_spec %}
54+
body = json.loads(json.dumps(transcoded_request['body']))
55+
{%- endif %}
56+
57+
uri = transcoded_request['uri']
58+
method = transcoded_request['method']
59+
60+
# Jsonify the query params
61+
query_params = json.loads(json.dumps(transcoded_request['query_params']))
62+
63+
# Send the request
64+
headers = dict(metadata)
65+
headers['Content-Type'] = 'application/json'
66+
67+
response = getattr(self._session, method)(
68+
"{host}{uri}".format(host=self._host, uri=uri),
69+
timeout=timeout,
70+
headers=headers,
71+
params=rest_helpers.flatten_query_params(query_params),
72+
{% if body_spec %}
73+
data=body,
74+
{% endif %}
75+
)
76+
77+
# In case of error, raise the appropriate core_exceptions.GoogleAPICallError exception
78+
# subclass.
79+
if response.status_code >= 400:
80+
raise core_exceptions.from_http_response(response)
81+
82+
{% if sig.response_type == "None" %}
83+
return self._interceptor.post_{{ name|snake_case }}(None)
84+
{% else %}
85+
86+
resp = {{ sig.response_type }}()
87+
resp = json_format.Parse(response.content.decode("utf-8"), resp)
88+
resp = self._interceptor.post_{{ name|snake_case }}(resp)
89+
return resp
90+
{% endif %}
91+
92+
{% endfor %}
93+
{% endif %} {# rest in opts.transport #}

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

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,16 @@ from google.protobuf import json_format
1919
{% if service.has_lro %}
2020
from google.api_core import operations_v1
2121
{% endif %}
22+
{% if opts.add_iam_methods or api.has_iam_mixin %}
23+
from google.iam.v1 import iam_policy_pb2 # type: ignore
24+
from google.iam.v1 import policy_pb2 # type: ignore
25+
{% endif %}
26+
{% if api.has_location_mixin %}
27+
from google.cloud.location import locations_pb2 # type: ignore
28+
{% endif %}
29+
{% if api.has_operations_mixin %}
30+
from google.longrunning import operations_pb2
31+
{% endif %}
2232
from requests import __version__ as requests_version
2333
import dataclasses
2434
import re
@@ -113,7 +123,25 @@ class {{ service.name }}RestInterceptor:
113123
"""
114124
return response
115125
{% endif %}
126+
{% endfor %}
127+
128+
{% for name, signature in api.mixin_api_signatures.items() %}
129+
def pre_{{ name|snake_case }}(self, request: {{signature.request_type}}, metadata: Sequence[Tuple[str, str]]) -> {{signature.response_type}}:
130+
"""Pre-rpc interceptor for {{ name|snake_case }}
116131

132+
Override in a subclass to manipulate the request or metadata
133+
before they are sent to the {{ service.name }} server.
134+
"""
135+
return request, metadata
136+
137+
def post_{{ name|snake_case }}(self, response: {{signature.request_type}}) -> {{signature.response_type}}:
138+
"""Post-rpc interceptor for {{ name|snake_case }}
139+
140+
Override in a subclass to manipulate the response
141+
after it is returned by the {{ service.name }} server but before
142+
it is returned to user code.
143+
"""
144+
return response
117145
{% endfor %}
118146

119147

@@ -344,7 +372,7 @@ class {{service.name}}RestTransport({{service.name}}Transport):
344372
including_default_value_fields=False,
345373
use_integers_for_enums={{ opts.rest_numeric_enums }}
346374
)
347-
{%- endif %}{# body_spec #}
375+
{%- endif %}
348376

349377
uri = transcoded_request['uri']
350378
method = transcoded_request['method']
@@ -430,6 +458,8 @@ class {{service.name}}RestTransport({{service.name}}Transport):
430458

431459
{% endfor %}
432460

461+
{% include '%namespace/%name_%version/%sub/services/%service/transports/_rest_mixins.py.j2' %}
462+
433463
@property
434464
def kind(self) -> str:
435465
return "rest"

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

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,67 @@
1+
{% if 'rest' in opts.transport %}
2+
{% for name, sig in api.mixin_api_signatures.items() %}
3+
4+
def test_{{ name|snake_case }}_rest_bad_request(transport: str = 'rest', request_type={{ sig.request_type }}):
5+
client = {{ service.client_name }}(
6+
credentials=ga_credentials.AnonymousCredentials(),
7+
transport=transport,
8+
)
9+
10+
request = request_type()
11+
request = json_format.ParseDict({{ api.mixin_http_options["{}".format(name)][0].sample_request }}, request)
12+
13+
# Mock the http request call within the method and fake a BadRequest error.
14+
with mock.patch.object(Session, 'request') as req, pytest.raises(core_exceptions.BadRequest):
15+
# Wrap the value into a proper Response obj
16+
response_value = Response()
17+
response_value.status_code = 400
18+
response_value.request = Request()
19+
req.return_value = response_value
20+
client.{{ name|snake_case }}(request)
21+
22+
@pytest.mark.parametrize("request_type", [
23+
{{ sig.request_type }},
24+
dict,
25+
])
26+
def test_{{ name|snake_case }}_rest(request_type):
27+
client = {{ service.client_name }}(
28+
credentials=ga_credentials.AnonymousCredentials(),
29+
transport="rest",
30+
)
31+
request_init = {{ api.mixin_http_options["{}".format(name)][0].sample_request }}
32+
request = request_type(**request_init)
33+
# Mock the http request call within the method and fake a response.
34+
with mock.patch.object(type(client.transport._session), 'request') as req:
35+
# Designate an appropriate value for the returned response.
36+
{% if sig.response_type == "None" %}
37+
return_value = None
38+
{% else %}
39+
return_value = {{ sig.response_type }}()
40+
{% endif %}
41+
42+
# Wrap the value into a proper Response obj
43+
response_value = Response()
44+
response_value.status_code = 200
45+
{% if sig.response_type == "None" %}
46+
json_return_value = '{}'
47+
{% else %}
48+
json_return_value = json_format.MessageToJson(return_value)
49+
{% endif %}
50+
51+
response_value._content = json_return_value.encode('UTF-8')
52+
req.return_value = response_value
53+
54+
response = client.{{ name|snake_case }}(request)
55+
56+
# Establish that the response is the type that we expect.
57+
{% if sig.response_type == "None" %}
58+
assert response is None
59+
{% else %}
60+
assert isinstance(response, {{ sig.response_type }})
61+
{% endif %}
62+
{% endfor %}
63+
{% endif %}
64+
165
{% if api.has_operations_mixin and ('grpc' in opts.transport or 'grpc_asyncio' in opts.transport) %}
266

367
{% if "DeleteOperation" in api.mixin_api_methods %}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ from proto.marshal.rules.dates import DurationRule, TimestampRule
2626
from requests import Response
2727
from requests import Request, PreparedRequest
2828
from requests.sessions import Session
29+
from google.protobuf import json_format
2930
{% endif %}
3031

3132
{# Import the service itself as well as every proto module that it imports. #}

0 commit comments

Comments
 (0)