Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add proper handling of query/path/body parameters for rest transport #702

Merged
merged 29 commits into from
Dec 8, 2020

Conversation

yon-mg
Copy link
Contributor

@yon-mg yon-mg commented Nov 20, 2020

Implementation and tests assume basic case of grpc transcoding for handling of all query/path parameters and body field.

These changes are dependent upon a PR in googleapis/proto-plus-python repo:
googleapis/proto-plus-python#164

@yon-mg yon-mg requested a review from a team as a code owner November 20, 2020 22:46
@google-cla google-cla bot added the cla: yes This human has signed the Contributor License Agreement. label Nov 20, 2020
@yon-mg yon-mg marked this pull request as draft November 20, 2020 23:15
Copy link
Contributor

@software-dov software-dov left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good so far, just took an early readthrough.

gapic/schema/wrappers.py Outdated Show resolved Hide resolved
potentialParams = {k: v for k, v in potentialParams.items() if v}
for i, (param_name, param_value) in enumerate(potentialParams.items()):
q = '?' if i == 0 else '&'
url += q + param_name + '=' + str(param_value).replace(' ', '+')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: prefer format strings to concatenating using +. It's easier to eyeball parse for longer or more convoluted strings.

url += "{q}{name}={value}".format(q=q, name=param_name, value=param_value.replace(' ', '+'))

url,
{%- if 'body' in method.http_opt.keys() %}
url
{%- if 'body' in method.http_opt.keys() %},
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need to use keys here, if 'body' in method.http_opt is more idiomatic.

'{{ field|camel_case }}': request.{{ field }},
{%- endfor %}
}
potentialParams = {k: v for k, v in potentialParams.items() if v}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very minor premature optimization nit: since we're immediately iterating over and then discarding this dictionary, we can turn it into a generator instead and prevent looping over the same data multiple times.

potential_params = ((k, v) for k, v in potentialParams.items() if v) # The parentheses make this a generator expression.
for i, (param_name, param_value) in enumerate(potentialParams):

This is a good rundown on generators and generator expressions. Dave Beazley also has a really fun youtube talk on generators.

str: The string in camel case with the first letter unchanged.
'''
items = s.split('_')
return items[0] + "".join([x.capitalize() for x in items[1:]])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: no need to make a list as the argument to join. We could replace the square brackets with parentheses to make it a generator expression, but there's a minor syntactic optimization where if a generator expression is the sole function argument you can remove the parens.

join(x.capitalize() for x in items[1:])

Comment on lines 292 to 293
#for method in service.methods.values():
#breakpoint()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a reminder to clean this up.

Copy link
Contributor

@vam-google vam-google left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall looks good, please try to get LGTM when Dov comes back and push this

@@ -45,3 +45,17 @@ def to_snake_case(s: str) -> str:

# Done; return the camel-cased string.
return s.lower()

def to_camel_case(s: str) -> str:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are there any built-in/standard python functions which do the same? Please prefer using standard ones to custom, if there are any.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm fairly certain there is no standard library function for to camel-case.

def path_params(self) -> Sequence[str]:
if self.http_opt is None:
return []
pattern = r'\{\w+\}'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add a comment that this handles grpc encoding in its simples case

@codecov
Copy link

codecov bot commented Dec 1, 2020

Codecov Report

Merging #702 (a4f34e7) into master (bdd5a66) will not change coverage.
The diff coverage is 100.00%.

Impacted file tree graph

@@            Coverage Diff            @@
##            master      #702   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           26        26           
  Lines         1578      1598   +20     
  Branches       320       324    +4     
=========================================
+ Hits          1578      1598   +20     
Impacted Files Coverage Δ
gapic/generator/generator.py 100.00% <100.00%> (ø)
gapic/schema/wrappers.py 100.00% <100.00%> (ø)
gapic/utils/__init__.py 100.00% <100.00%> (ø)
gapic/utils/case.py 100.00% <100.00%> (ø)

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update bdd5a66...a4f34e7. Read the comment docs.

Copy link
Contributor

@vchudnov-g vchudnov-g left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I flagged my more important comments with !!

It looks like this generator does not have golden files or baselines, correct? Too bad; it would be good to see a sample of the generated GAPIC showing off these features.

gapic/schema/wrappers.py Outdated Show resolved Hide resolved
gapic/schema/wrappers.py Outdated Show resolved Hide resolved
@@ -135,28 +135,42 @@ class {{ service.name }}RestTransport({{ service.name }}Transport):

{%- if 'body' in method.http_opt.keys() %}
# Jsonify the input
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: for clarity, maybe s/input/request/

data = {{ method.output.ident }}.to_json(
{%- if method.http_opt['body'] == '*' %}
{%- if method.http_opt['body'] != '*' %}
data = {{ method.input.fields[method.http_opt['body']].type.ident }}.to_json(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

!! This will handle dot-notation nested fields, correct? If not, add a TODO.

Context: while https://github.com/googleapis/googleapis/blob/master/google/api/http.proto#L350 suggests that's not allowed, some APIs do have it (see gRPC transcoding doc), eg. https://github.com/googleapis/googleapis/blob/836f0eaf5f21f300f63ac635e5ef263d183e0cdd/google/cloud/dialogflow/cx/v3beta1/session.proto#L95

Copy link
Contributor Author

@yon-mg yon-mg Dec 1, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It does not handle it but I have added a todo in the appropriate place in wrappers.Method. Pretty much anything dealing with full handling of grpc transcoding (or special cases outside grpc transcoding) is not yet taken care of in this PR as per the PR desc.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK. As discussed, we should err on the side of duplicate TODOs rather than too few. If we address one, we're likely to see the others and address/delete them.

including_default_value_fields=False
)
{%- else %}
data = {{ method.input.ident }}.to_json(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does request already have the body and query params stripped out at this point, so they don't get sent in two places?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As we clarified yesterday: params in the path may be (but don't have to be) repeated in the body if we're using "*", though we should never have a body: "foo" if foo is a path param. And query params are whatever's left over from what's not included in the path and body. So we just need to ensure no body params wind up as query params.

'{{ field|camel_case }}': request.{{ field }},
{%- endfor %}
}
potentialParams = ((k, v) for k, v in potentialParams.items() if v)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is fine, but another approach is to potentialParams=["{k}={v}".format(k=k, v=v) for k, v in ...] here and then url += "?{}".format("&".join(potentialParams)) below



def to_camel_case(s: str) -> str:
'''Convert any string to camel case.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: In this and the previous, pre-existing function, we're not touching spaces, right? Might be worth mentioning that.

str: The string in lower camel case.
'''

items = re.split(r'[_-]', to_snake_case(s))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm surprised by the hyphen -. It almost seems like that should be handled by to_snake_case, though that's outside the scope of this PR. Does it come up in practice, that an input to this function has hyphens/kebab-case?

Opinion, @software-dov ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It actually might handle it: I didn't check. As far as use, it doesn't get used but I wouldn't want a future user to attempt o try it and have it fail.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My vote: write a test for to_snake_case's behavior, add the functionality if the test fails, just split on '_'.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If to_snake_case does not handle it, could adding the functionality break existing clients? is there an easy way to check? (This is why I was suggesting "outside the scope of this PR")

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I doubt it would break anything since it's additive but this is why I didn't change to_snake_case's behavior

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But the generator has already produced libraries that were published, so if one of those had a hyphen somewhere, it made it through to_snake_case and changing that implementation could lead to an incompatible surface.

return [x[1:-1] for x in re.findall(pattern, self.http_opt['url'])]

@property
def query_params(self) -> Set[str]:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just curious: why can't query_params and path_params return the same type, rather than a Set and a Sequence?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suppose they could. Just thought it's more appropriate since ordering seems important for path_params but not for query_params.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As discussed. That seems reasonable, but a complication is that repeated fields map to repeated query params. In that case, a sequence may be best. (If order doesn't matter, a multiset might be enough, but we can't assume that order doesn't matter for repeated fields)

Copy link
Contributor Author

@yon-mg yon-mg Dec 3, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right set is not appropriate even if order doesn't matter but I should mention though that once query param logic is moved out, this won't matter anymore.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True!

Copy link
Contributor

@vchudnov-g vchudnov-g left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking good. Two of my previous !! comments are still open. The truthiness one we should mark with a TODO, since we can't fully resolve it right now. The URL-encoding one, if it's not trivial to address now, we should mark with a TODO and address in a follow-up PR.

@@ -767,6 +767,29 @@ def http_opt(self) -> Optional[Dict[str, str]]:
# TODO(yon-mg): enums for http verbs?
return answer

@property
def path_params(self) -> Sequence[str]:
"""Return the path parameters found in the http annotation url"""
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WDYM? You're returning the {foo} which is a field path, right?

gapic/schema/wrappers.py Outdated Show resolved Hide resolved
gapic/schema/wrappers.py Show resolved Hide resolved
@vam-google vam-google marked this pull request as ready for review December 4, 2020 19:54
Copy link
Contributor

@vchudnov-g vchudnov-g left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good! Thanks for doing this!

For clarity when looking back at this commit, maybe you can expand in the merge commit description what the "basic case" is: path params in simple form, no body, no query param.

return [x[1:-1] for x in re.findall(pattern, self.http_opt['url'])]

@property
def query_params(self) -> Set[str]:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True!

{%- for field in method.query_params %}
'{{ field|camel_case }}': request.{{ field }},
{%- endfor %}
}
potentialParams = ((k, v) for k, v in potentialParams.items() if v)
for i, (param_name, param_value) in enumerate(potentialParams):
# TODO(yon-mg): further discussion needed whether 'python truthiness' is appropriate here
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the TODO on truthiness!

# discards default values
# TODO(yon-mg): add test for proper url encoded strings
query_params = ((k, v) for k, v in query_params.items() if v)
for i, (param_name, param_value) in enumerate(query_params):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Do consider the string.Join suggestion from earlier (but it's certainly not a blocker)

@yon-mg yon-mg merged commit 6b2de5d into googleapis:master Dec 8, 2020
gcf-merge-on-green bot pushed a commit that referenced this pull request Dec 8, 2020
@busunkim96 busunkim96 linked an issue Jan 6, 2021 that may be closed by this pull request
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
cla: yes This human has signed the Contributor License Agreement.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Re-add HTTP transport and make it the fallback
4 participants