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

Use Optional for Query Params Instead of Union[Unset, ...] #285

Closed
bowenwr opened this issue Jan 5, 2021 · 9 comments · Fixed by benchling/openapi-python-client#40 or #331
Closed
Labels
✨ enhancement New feature or improvement
Milestone

Comments

@bowenwr
Copy link
Contributor

bowenwr commented Jan 5, 2021

Is your feature request related to a problem? Please describe.

Although I'd like to keep Unset for body params, it makes a lot less sense for query params. I'd like non-required query params to return to the use of Optional.

While it would be inconsistent with the way we handle body params, I like that it encapsulates Unset (which is something of a workaround) more tightly.

Describe the solution you'd like

Given:

'/things':
    get:
      tags:
      - things
      operationId: getThings
      parameters:
      - name: size
        in: query
        schema:
          type: int

I'd like the signature to look like:

def sync_detailed(
    *,
    client: Client,
    size: Optional[int] = None,
) -> Response[Thing]:

Instead of:

def sync_detailed(
    *,
    client: Client,
    size: Union[Unset, int] = UNSET,
) -> Response[Thing]:

Describe alternatives you've considered

We have several endpoints with a large number of optional query params that default to None in the wrapping interface. Will probably just iterate the dict and assign Unset everywhere we have None now.

Additional context

I feel like I did this to myself having requested the original Unset feature. 😅

@bowenwr bowenwr added the ✨ enhancement New feature or improvement label Jan 5, 2021
@dbanty
Copy link
Collaborator

dbanty commented Jan 5, 2021

Oof. There's definitely not a clear answer here. The purpose of Unset was to differentiate between "not required" and "nullable". I feel like going back to using Optional as "not required" in some locations but not others will just cause confusion. Even worse, you've made me realize that it's not obvious what we should do with a query param which has nullable: True in the OpenAPI document.

The OpenAPI spec doesn't seem to draw any distinction between location (e.g. nullable is not forbidden in query parameters). However, there is also no obvious way to send nulls in query parameters because... well they're stings. So it seems to me there is ambiguity in the spec which means it's going to be very implementation-specific how nulls are dealt with (if at all).

If you pass a None to httpx in the params= argument, it will encode as an empty string (e.g. ?a=) which is definitely not the same as null. However, requests (which is a much more familiar library to most Python devs) will completely omit query params with a value of None.

@bowenwr
Copy link
Contributor Author

bowenwr commented Jan 5, 2021

Yeah, I can totally appreciate that it's confusing to have different paths for different parameter types. I think of None / null as omission vs. empty literal. In general I'm trying to avoid exposing Unset externally (we are wrapping the client) so maybe I will accept None and convert it to Unset or just do something in our fork but not upstream it. Thanks for your thoughts on this!

@dbanty
Copy link
Collaborator

dbanty commented Jan 5, 2021

Maybe it would be useful to have a configurable None behavior for query params since there is no universal standard. One of:

  1. Omit
  2. empty string
  3. URL encoded NULL (%00 according to this SO post)

That wouldn't fix the typing problem, unless the document specified the param as nullable the generated client wouldn't accept None still, but if you're wrapping the client you could cast or # type: ignore?

We could also take the config a step further and if None behavior is omit then always accept None for non-required params. Gets a little icky with special cases in the generated code though.

Another option is to just say that in this project nullable is the same as not required for query params and make the type Union[Unset, None, <whatever>]. I've never needed to actually pass null in a query param so I'm fine with that.

@bowenwr
Copy link
Contributor Author

bowenwr commented Jan 6, 2021

Another option is to just say that in this project nullable is the same as not required for query params and make the type Union[Unset, None, ]. I've never needed to actually pass null in a query param so I'm fine with that.

This is probably the easiest option that solves all our problems if we're treating None as omit there, assuming no one objects or has a use case around sending NULL.

@dbanty
Copy link
Collaborator

dbanty commented Jan 6, 2021

Sounds like a plan then. And if anyone ends up having different requirements around nullable we can revisit having configurable behavior with this as the default.

@dbanty
Copy link
Collaborator

dbanty commented Feb 1, 2021

@bowenwr , @forest-benchling , and @emann I want to pull the discussion which has sort of spiraled out in #297 back to the issue here. In thinking about this more I want to take a different approach. I think we should not be changing type annotation based on location (query) since that's causing a lot of confusion and cascading code changes. Instead, I think we leave the function/type definitions as they are (Unset for not required, None for nullable).

Instead, I think the solution here is to omit any query param with the value None in generated functions which make requests. This actually seems to be more in line with the spec which has an allowEmptyValue param which defaults to false, indicating to me that not including values which were set to null is the correct default behavior anyway.

This is sort of a half solution to your specific problem, but I think the best way forward. The types will show incorrectly still if you pass None into an endpoint param which is not nullable, but the behavior of passing that None will be as expected.

@emann
Copy link
Collaborator

emann commented Feb 1, 2021

Definitely seems like the cleanest solution + gives us an easy way to support allowEmptyValue in the future. I think keeping the semantics of Unset/not required and None/nullable intact is also a good idea if nothing else for our own sanity while developing.

@forest-benchling
Copy link
Collaborator

@dbanty @emann @bowenwr

To me, it makes it more confusing for the type annotation to not reflect the implementation. The type annotation would be saying None is not accepted, but we're saying the implementation would allow it. Not only would this be confusing for developers of this repo, it would also mean we'd have to silence MyPy errors because MyPy would be confused why we are still checking for None.

I'm not sure the allowEmptyValue param is a reason to do this--firstly, in the spec it says it has been deprecated and is no longer recommended, and secondly, what I gathered from my skimming of OAI/OpenAPI-Specification#1573 is that it has more to do with empty strings than with None (although I could be wrong as I didn't read the entire thread).

What I'm leaning towards is keeping the OpenAPI spec, the type annotations, and the implementation all in alignment, for our own sanity--AKA, the status quo.

However, I do think that is a very reasonable use case to want to pass None instead of Unset as query parameters. For the SDK we're building, we don't want Unset to go spilling out into SDK and all our application code. Therefore, here is an idea: What if we were to allow a flag, either in sync_detailed or in the client, to convert None to Unset at run-time for query parameters?

@dbanty
Copy link
Collaborator

dbanty commented Feb 8, 2021

I think that for the sake of simplicity (and expedience, because I want to move this issue along) I'm going to go with my solution above. Unset will continue to be used for not required and None for nullable. Then the only change to make us compliant with OpenAPI's defaults is to omit any None instead of passing it through as an empty string.

While the original issue here is spawned from Benchling's need to surface different types for their SDK, any solution has to be generally applicable enough to not cause undue complexity (either for consumers or within the codebase itself). That being said, the unstable custom templates feature exists for just such reasons: folks needing customization that doesn't necessarily belong in this general purpose product. You could probably achieve your original ask (replacing Unset with None) by providing a custom template for endpoint_macros.py.jinja which is the same except in the arguments macro it does a string replace of Unset with None for query params.

If there is a way we can make the custom templates feature more useful to you for this, let me know. We could, for example, potentially make the templates a separate package which you could fork and maintain as your custom templates (to make upstream changes easier to pull in). It might also make sense for all the type annotation building stuff to live in templates instead of the core Python code.

Also if you think I'm missing something here and that my solution isn't the ideal one for general purpose clients, I'm always happy to reconsider.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment