-
-
Notifications
You must be signed in to change notification settings - Fork 228
Change optional Open API query parameters to allow None
#297
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
Change optional Open API query parameters to allow None
#297
Conversation
Codecov Report
@@ Coverage Diff @@
## main #297 +/- ##
=========================================
Coverage 100.00% 100.00%
=========================================
Files 47 47
Lines 1385 1392 +7
=========================================
+ Hits 1385 1392 +7
Continue to review full report at Codecov.
|
@dbanty Any thoughts on this? |
I am planning on reviewing this weekend (which is starting soon!). So I should have some feedback or an approval for you by Tuesday 😊 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Awesome work! I really love cleaning up all those type string functions to centralize the nullable/required logic more. My comments are mostly split between some if-logic consolidation and the big question of whether we need more logic in the templates to ensure that nullable is always the same as not-required for query params.
Also, you'll need to pull main into your branch because I fixed that py
thing as well while I was doing something else 😦. The reason it fails on the local task but not in CI is because it's required only by a dev dependency, and CI intentionally leaves those out since they don't matter to the end product. Probably worth updating the task
to do the same at some point 🧐.
Thanks again! Looking forward to your response / follow-up changes!
if self.required: | ||
if self.nullable: | ||
return f"Union[None, {self._get_inner_prop_string()}]" | ||
else: | ||
return type_string | ||
else: | ||
if self.nullable: | ||
return f"Union[Unset, None, {self._get_inner_prop_string()}]" | ||
else: | ||
if query_parameter: | ||
# For query parameters, None has the same meaning as Unset | ||
return f"Union[Unset, None, {self._get_inner_prop_string()}]" | ||
else: | ||
return f"Union[Unset, {self._get_inner_prop_string()}]" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this cleans up the logic a little bit (one case per output option) and covers the case of required and nullable and query_parameter
correctly. Spacing may be wrong because I'm editing in GitHub >.>.
if self.required: | |
if self.nullable: | |
return f"Union[None, {self._get_inner_prop_string()}]" | |
else: | |
return type_string | |
else: | |
if self.nullable: | |
return f"Union[Unset, None, {self._get_inner_prop_string()}]" | |
else: | |
if query_parameter: | |
# For query parameters, None has the same meaning as Unset | |
return f"Union[Unset, None, {self._get_inner_prop_string()}]" | |
else: | |
return f"Union[Unset, {self._get_inner_prop_string()}]" | |
if self.required and not self.nullable: | |
return type_string | |
if query_parameter or (self.nullable and not self.required): | |
# For query parameters, nullable and required are the same | |
return f"Union[Unset, None, {self._get_inner_prop_string()}]" | |
if self.nullable: | |
return f"Union[None, {self._get_inner_prop_string()}]" | |
return f"Union[Unset, {self._get_inner_prop_string()}]" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the case that differs is if query_parameter=True, required=True, nullable=True
. In the original code, we get f"Union[None, {self._get_inner_prop_string()}]"
, whereas in the suggestion, we get f"Union[Unset, None, {self._get_inner_prop_string()}]"
. It seems like the original code would be correct in that case, unless it's the case that query parameters cannot exist with required=True, nullable=True
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, hmm, it seems like the semantics are weird for a query parameter with required=True, nullable=True
...what would it mean for you to pass None
?
This also impacts your other comment in endpoint_macros.pyi
...if we decide that we aren't gonna allow passing None
, then I think we also need {% if property.required and not property.nullable %}
in L25.
This is so brain-twisting 🤔
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@dbanty (cc @bowenwr) OK I think I figured out why I'm so confused. Seems like for query parameters, it's redundant to have BOTH required
and nullable
as properties, because there's no distinction between None
and UNSET
.
In the current implementation, we have:
However, it's confusing because in the last row, we specifically say nullable=False
, but still it's allowing None
.
Is it possible to ban the nullable
attribute on query parameters? That would simplify things a lot. Though it would be a breaking change.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think what we're going for here is to interpret both nullable and required to mean the same thing in the context of query parameters. Basically, there is no standard way to pass null
through a query parameter, so rather than setting our own decision we're opting to not have a null
value. In which case, null
and not required
become the same thing semantically when using this generator, and therefore should be the same signature.
Interestingly, in my response to #295 I encountered the "style" attribute which does seem to indicate that an "empty" value should be provided as empty, as the generated clients do today. It's not clear if empty is equivalent to null though...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see. In that case @dbanty do you think it would be reasonable to stick to the original implementation, which converts query_parameter=True, required=True, nullable=True
into Union[None, {self._get_inner_prop_string()}]
?
if self.required: | ||
if self.nullable: | ||
return f"Optional[{type_string}]" | ||
else: | ||
return type_string | ||
else: | ||
if self.nullable: | ||
return f"Union[Unset, None, {type_string}]" | ||
else: | ||
if query_parameter: | ||
# For query parameters, None has the same meaning as Unset | ||
return f"Union[Unset, None, {type_string}]" | ||
else: | ||
return f"Union[Unset, {type_string}]" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I can't include it in a suggestion because of the way GitHub split the lines, but you could make this first case the same if check as no_optional
. This is basically the same change I suggested for UnionProperty
.
if self.required: | |
if self.nullable: | |
return f"Optional[{type_string}]" | |
else: | |
return type_string | |
else: | |
if self.nullable: | |
return f"Union[Unset, None, {type_string}]" | |
else: | |
if query_parameter: | |
# For query parameters, None has the same meaning as Unset | |
return f"Union[Unset, None, {type_string}]" | |
else: | |
return f"Union[Unset, {type_string}]" | |
if self.required and not self.nullable: | |
return type_string | |
if query_parameter or (self.nullable and not self.required): | |
# For query parameters, nullable has the same meaning as required | |
return f"Union[Unset, None, {type_string}]" | |
if self.nullable: | |
return f"Optional[{type_string}]" | |
return f"Union[Unset, {type_string}]" |
@@ -33,7 +33,7 @@ params: Dict[str, Any] = { | |||
} | |||
{% for property in endpoint.query_parameters %} | |||
{% if not property.required %} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think to truly make not-required the same as nullable for query params we want to add this.
{% if not property.required %} | |
{% if not property.required or property.nullable %} |
@@ -9,12 +9,12 @@ if _{{ property.python_name }} is not None: | |||
{% endif %} | |||
{% endmacro %} | |||
|
|||
{% macro transform(property, source, destination, declare_type=True) %} | |||
{% macro transform(property, source, destination, declare_type=True, query_parameter=False) %} | |||
{% if property.required %} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we also need this? I could be confusing myself now 🥸
{% if property.required %} | |
{% if property.required and (not query_parameter or not property.nullable) %} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Wouldn't we be safe in that case by the {% if property.nullable %}if {{ source }} else None {%endif%}
on L14?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh I see, I was thinking that in endpoint_macros we were only ignoring params that were UNSET, not also None
.
Actually though I think I just spotted another issue but I can't comment on it. Line 25 in endpoint_macros will include all params if they are required, even if they are None
. If we truly want to treat nullable and optional as the same thing we'll need to adjust that if
and the one on line 35.
@@ -14,7 +14,7 @@ if _{{ property.python_name }} is not None: | |||
{% endif %} | |||
{% endmacro %} | |||
|
|||
{% macro transform(property, source, destination, declare_type=True) %} | |||
{% macro transform(property, source, destination, declare_type=True, query_parameter=False) %} | |||
{% if property.required %} | |||
{% if property.nullable %} | |||
{{ destination }} = {{ source }}.isoformat() if {{ source }} else None |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There's probably something in here too. I guess we need some end to end tests with just nullable or just non-required to make sure we're covering those bases? We don't want to ever pass None to a query param. I'm going to stop writing this same note over and over now 😅
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I added every permutation of nullable, required
for the datetime
query parameter to the E2E test.
However, I wasn't sure how far to take it--ideally it would cover all types, but that would be an exponential explosion 😬
@@ -31,13 +31,35 @@ def test_get_type_string(self, mocker): | |||
assert p.get_type_string(no_optional=True) == base_type_string | |||
|
|||
p = Property(name="test", required=False, default=None, nullable=True) | |||
assert p.get_type_string() == f"Union[Unset, Optional[{base_type_string}]]" | |||
assert p.get_type_string() == f"Union[Unset, None, {base_type_string}]" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Honestly I like this style better overall. I think the Optional
name gets confusing in some cases (the whole reason we're changing this behavior...).
|
||
p = Property(name="test", required=False, default=None, nullable=False) | ||
assert p.get_type_string(query_parameter=True) == f"Union[Unset, None, {base_type_string}]" | ||
assert p.get_type_string(no_optional=True, query_parameter=True) == base_type_string |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This feels like maybe a good opportunity to use @pytest.mark.parametrize
to make the different test cases and expected outcomes easier to read. Not at all required (clearly lots of other tests could use it but don't) but could be nice.
Co-authored-by: Dylan Anthony <43723790+dbanty@users.noreply.github.com>
@dbanty PTAL at the new changeset. While addressing your comments, I realized that the existing code generation wasn't correctly handling the case where a query parameter is |
@dbanty (cc @bowenwr) I made another change in the latest commit to pass a
|
I haven't forgotten about this I promise, just been a bit busy 😅. Thanks so much for putting all this work in, I'll review as soon as I can! |
return type_string | ||
if self.required: | ||
if self.nullable: | ||
return f"Optional[{type_string}]" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we're still not in alignment on design here. nullable
should be the same as not required
semantically when considering a query parameter. So if required and nullable and query_parameter
it should be returning f"Union[Unset, None, {type_string}]"
. I believe my previous suggestion to this (and the other, similar) code block makes this happen.
It's possible I'm missing or misunderstanding something about this, and if so please let me know. Otherwise I'll hold off additional review until you make that change.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm...if required and nullable and query_parameter
are all True
, wouldn't we want the type to be Optional[{type_string}]
, not allowing Unset
? Otherwise, if you allowed Unset
, why would you make it "required"?
Or is it different for query parameters, where "required" would still allow Unset
to pass through.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's different for query parameters. Basically we're not allowing nullable
query parameters and instead treating nullable
like not required
. Additionally, we're allowing None
to function just like Unset
, but either way we're not passing through the parameter. I think having a different result for nullable and required
might infer to the user that we can/will actually pass through a None
, which we will not.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@dbanty I started implementing this approach, but it causes a bunch of other errors because in the templates, we are assuming that if something is required and nullable
, then we only need to check for None
, whereas with this change we now also need to check for Unset
.
I think this is getting pretty complicated and hard to reason about, and I worry about future maintainability. Maybe we should just make the type Optional
if it's either not required or nullable, and throw away Unset
in the case of query parameters? cc @bowenwr
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In my original implementation of Unset
I didn't apply it to query parameters. I'm personally in favor of that approach.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we have to pick one or the other I'd prefer Unset
over None
because it better indicates what we're doing with that parameter to the user. It also happens to be the behavior of the released version for non-required query params. What's your opinion @emann?
Closing in favor of #331 which, while not nearly as cool, brings us in line with the spec with a relatively simple change. Thank you so much for all your hard work on this @forest-benchling , sorry we couldn't come up with a better solution that would more cleanly solve Benchling's problem while appeasing my annoying sensibilities 😅. Hopefully #331 is good enough at least for now and we can talk about other options in the future if it's not. |
Fixes #285.
Instead of methods like
we will now generate
and treat
None
the same asUnset
.This is a bit different from the original proposal in the linked issue, but if you scroll down you can see that it's what was agreed upon in the end IIUC (though I wasn't sure if we agreed on what the default value should be). In addition, it has the benefit of preserving back-compat.
As part of this issue, I also tried to collapse nested
Optional
andUnion
types, so that what was formerlyUnion[Unset, Optional[int]]
orOptional[Union[Unset, int]]
is nowUnion[Unset, None, int]
.