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

Commit 6b2de5d

Browse files
yon-mgsoftware-dov
andauthored
feat: add proper handling of query/path/body parameters for rest transport (#702)
* feat: add proper handling of query/path/body parameters for rest transport * fix: typing errors * Update case.py * fix: minor changes adding a test, refactor and style check * fix: camel_case bug with constant case * fix: to_camel_case to produce lower camel case instead of PascalCase where relevant * fix: addressing pr comments * fix: adding appropriate todos, addressing comments * fix: dataclass dependency issue * Update wrappers.py Co-authored-by: Dov Shlachter <dovs@google.com>
1 parent bdd5a66 commit 6b2de5d

10 files changed

Lines changed: 161 additions & 15 deletions

File tree

.circleci/config.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,7 @@ jobs:
364364
365365
cd ..
366366
nox -s showcase_mtls_alternative_templates
367+
# TODO(yon-mg): add compute unit tests
367368
showcase-unit-3.6:
368369
docker:
369370
- image: python:3.6-slim

gapic/generator/generator.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ def __init__(self, opts: Options) -> None:
5454
# Add filters which templates require.
5555
self._env.filters["rst"] = utils.rst
5656
self._env.filters["snake_case"] = utils.to_snake_case
57+
self._env.filters["camel_case"] = utils.to_camel_case
5758
self._env.filters["sort_lines"] = utils.sort_lines
5859
self._env.filters["wrap"] = utils.wrap
5960
self._env.filters["coerce_response_name"] = coerce_response_name

gapic/schema/wrappers.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -767,6 +767,31 @@ def http_opt(self) -> Optional[Dict[str, str]]:
767767
# TODO(yon-mg): enums for http verbs?
768768
return answer
769769

770+
@property
771+
def path_params(self) -> Sequence[str]:
772+
"""Return the path parameters found in the http annotation path template"""
773+
# TODO(yon-mg): fully implement grpc transcoding (currently only handles basic case)
774+
if self.http_opt is None:
775+
return []
776+
777+
pattern = r'\{(\w+)\}'
778+
return re.findall(pattern, self.http_opt['url'])
779+
780+
@property
781+
def query_params(self) -> Set[str]:
782+
"""Return query parameters for API call as determined by http annotation and grpc transcoding"""
783+
# TODO(yon-mg): fully implement grpc transcoding (currently only handles basic case)
784+
# TODO(yon-mg): remove this method and move logic to generated client
785+
if self.http_opt is None:
786+
return set()
787+
788+
params = set(self.path_params)
789+
body = self.http_opt.get('body')
790+
if body:
791+
params.add(body)
792+
793+
return set(self.input.fields) - params
794+
770795
# TODO(yon-mg): refactor as there may be more than one method signature
771796
@utils.cached_property
772797
def flattened_fields(self) -> Mapping[str, Field]:

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

Lines changed: 41 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -133,31 +133,59 @@ class {{ service.name }}RestTransport({{ service.name }}Transport):
133133
{%- endif %}
134134
"""
135135

136-
{%- if 'body' in method.http_opt.keys() %}
137-
# Jsonify the input
138-
data = {{ method.output.ident }}.to_json(
139-
{%- if method.http_opt['body'] == '*' %}
136+
{# TODO(yon-mg): refactor when implementing grpc transcoding
137+
- parse request pb & assign body, path params
138+
- shove leftovers into query params
139+
- make sure dotted nested fields preserved
140+
- format url and send the request
141+
#}
142+
{%- if 'body' in method.http_opt %}
143+
# Jsonify the request body
144+
{%- if method.http_opt['body'] != '*' %}
145+
body = {{ method.input.fields[method.http_opt['body']].type.ident }}.to_json(
146+
request.{{ method.http_opt['body'] }},
147+
including_default_value_fields=False
148+
)
149+
{%- else %}
150+
body = {{ method.input.ident }}.to_json(
140151
request
141-
{%- else %}
142-
request.body
143-
{%- endif %}
144152
)
145153
{%- endif %}
154+
{%- endif %}
146155

147156
{# TODO(yon-mg): Write helper method for handling grpc transcoding url #}
148157
# TODO(yon-mg): need to handle grpc transcoding and parse url correctly
149-
# current impl assumes simpler version of grpc transcoding
150-
# Send the request
158+
# current impl assumes basic case of grpc transcoding
151159
url = 'https://{host}{{ method.http_opt['url'] }}'.format(
152160
host=self._host,
153-
{%- for field in method.input.fields.keys() %}
161+
{%- for field in method.path_params %}
154162
{{ field }}=request.{{ field }},
155163
{%- endfor %}
156164
)
165+
166+
{# TODO(yon-mg): move all query param logic out of wrappers into here to handle
167+
nested fields correctly (can't just use set of top level fields
168+
#}
169+
# TODO(yon-mg): handle nested fields corerctly rather than using only top level fields
170+
# not required for GCE
171+
query_params = {
172+
{%- for field in method.query_params %}
173+
'{{ field|camel_case }}': request.{{ field }},
174+
{%- endfor %}
175+
}
176+
# TODO(yon-mg): further discussion needed whether 'python truthiness' is appropriate here
177+
# discards default values
178+
# TODO(yon-mg): add test for proper url encoded strings
179+
query_params = ((k, v) for k, v in query_params.items() if v)
180+
for i, (param_name, param_value) in enumerate(query_params):
181+
q = '?' if i == 0 else '&'
182+
url += "{q}{name}={value}".format(q=q, name=param_name, value=param_value.replace(' ', '+'))
183+
184+
# Send the request
157185
{% if not method.void %}response = {% endif %}self._session.{{ method.http_opt['verb'] }}(
158-
url,
159-
{%- if 'body' in method.http_opt.keys() %}
160-
json=data,
186+
url
187+
{%- if 'body' in method.http_opt %},
188+
json=body,
161189
{%- endif %}
162190
)
163191
{%- if not method.void %}

gapic/utils/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from gapic.utils.cache import cached_property
1616
from gapic.utils.case import to_snake_case
17+
from gapic.utils.case import to_camel_case
1718
from gapic.utils.code import empty
1819
from gapic.utils.code import nth
1920
from gapic.utils.code import partition
@@ -38,6 +39,7 @@
3839
'rst',
3940
'sort_lines',
4041
'to_snake_case',
42+
'to_camel_case',
4143
'to_valid_filename',
4244
'to_valid_module_name',
4345
'wrap',

gapic/utils/case.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,19 @@ def to_snake_case(s: str) -> str:
4545

4646
# Done; return the camel-cased string.
4747
return s.lower()
48+
49+
50+
def to_camel_case(s: str) -> str:
51+
'''Convert any string to camel case.
52+
53+
This is provided to templates as the ``camel_case`` filter.
54+
55+
Args:
56+
s (str): The input string, provided in any sane case system
57+
58+
Returns:
59+
str: The string in lower camel case.
60+
'''
61+
62+
items = re.split(r'[_-]', to_snake_case(s))
63+
return items[0].lower() + "".join(x.capitalize() for x in items[1:])

noxfile.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ def unit(session):
5252
)
5353

5454

55+
# TODO(yon-mg): -add compute context manager that includes rest transport
56+
# -add compute unit tests
57+
# (to test against temporarily while rest transport is incomplete)
58+
# (to be removed once all features are complete)
5559
@contextmanager
5660
def showcase_library(
5761
session, templates="DEFAULT", other_opts: typing.Iterable[str] = ()
@@ -87,6 +91,8 @@ def showcase_library(
8791

8892
# Write out a client library for Showcase.
8993
template_opt = f"python-gapic-templates={templates}"
94+
# TODO(yon-mg): add "transports=grpc+rest" when all rest features required for
95+
# Showcase are implemented i.e. (grpc transcoding, LROs, etc)
9096
opts = "--python_gapic_opt="
9197
opts += ",".join(other_opts + (f"{template_opt}",))
9298
cmd_tup = (

setup.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,9 @@
5151
"protobuf >= 3.12.0",
5252
"pypandoc >= 1.4",
5353
"PyYAML >= 5.1.1",
54-
"dataclasses<0.8; python_version < '3.7'"
54+
"dataclasses < 0.8; python_version < '3.7'"
5555
),
56-
extras_require={':python_version<"3.7"': ("dataclasses >= 0.4",),},
56+
extras_require={':python_version<"3.7"': ("dataclasses >= 0.4, < 0.8",),},
5757
tests_require=("pyfakefs >= 3.6",),
5858
python_requires=">=3.6",
5959
classifiers=(

tests/unit/schema/wrappers/test_method.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,57 @@ def test_method_http_opt_no_http_rule():
279279
assert method.http_opt == None
280280

281281

282+
def test_method_path_params():
283+
# tests only the basic case of grpc transcoding
284+
http_rule = http_pb2.HttpRule(post='/v1/{project}/topics')
285+
method = make_method('DoSomething', http_rule=http_rule)
286+
assert method.path_params == ['project']
287+
288+
289+
def test_method_path_params_no_http_rule():
290+
method = make_method('DoSomething')
291+
assert method.path_params == []
292+
293+
294+
def test_method_query_params():
295+
# tests only the basic case of grpc transcoding
296+
http_rule = http_pb2.HttpRule(
297+
post='/v1/{project}/topics',
298+
body='address'
299+
)
300+
input_message = make_message(
301+
'MethodInput',
302+
fields=(
303+
make_field('region'),
304+
make_field('project'),
305+
make_field('address')
306+
)
307+
)
308+
method = make_method('DoSomething', http_rule=http_rule,
309+
input_message=input_message)
310+
assert method.query_params == {'region'}
311+
312+
313+
def test_method_query_params_no_body():
314+
# tests only the basic case of grpc transcoding
315+
http_rule = http_pb2.HttpRule(post='/v1/{project}/topics')
316+
input_message = make_message(
317+
'MethodInput',
318+
fields=(
319+
make_field('region'),
320+
make_field('project'),
321+
)
322+
)
323+
method = make_method('DoSomething', http_rule=http_rule,
324+
input_message=input_message)
325+
assert method.query_params == {'region'}
326+
327+
328+
def test_method_query_params_no_http_rule():
329+
method = make_method('DoSomething')
330+
assert method.query_params == set()
331+
332+
282333
def test_method_idempotent_yes():
283334
http_rule = http_pb2.HttpRule(get='/v1/{parent=projects/*}/topics')
284335
method = make_method('DoSomething', http_rule=http_rule)

tests/unit/utils/test_case.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,19 @@ def test_camel_to_snake():
2525

2626
def test_constant_to_snake():
2727
assert case.to_snake_case('CONSTANT_CASE_THING') == 'constant_case_thing'
28+
29+
30+
def test_pascal_to_camel():
31+
assert case.to_camel_case('PascalCaseThing') == 'pascalCaseThing'
32+
33+
34+
def test_snake_to_camel():
35+
assert case.to_camel_case('snake_case_thing') == 'snakeCaseThing'
36+
37+
38+
def test_constant_to_camel():
39+
assert case.to_camel_case('CONSTANT_CASE_THING') == 'constantCaseThing'
40+
41+
42+
def test_kebab_to_camel():
43+
assert case.to_camel_case('kebab-case-thing') == 'kebabCaseThing'

0 commit comments

Comments
 (0)