- 
          
- 
                Notifications
    You must be signed in to change notification settings 
- Fork 257
          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}]" | 
| {% endfor %} | ||
| } | ||
| {% 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 %} | 
|  | ||
| {% 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.
| {% 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 😬
|  | ||
| 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
Nonethe 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
OptionalandUniontypes, so that what was formerlyUnion[Unset, Optional[int]]orOptional[Union[Unset, int]]is nowUnion[Unset, None, int].