Skip to content

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

Closed
benchling/openapi-python-client
#40

Description

@bowenwr

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. 😅

Activity

  1. dbanty commented on Jan 5, 2021

    @dbanty
    Collaborator

    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.

  2. bowenwr commented on Jan 5, 2021

    @bowenwr
    ContributorAuthor

    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!

  3. dbanty commented on Jan 5, 2021

    @dbanty
    Collaborator

    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.

  4. bowenwr commented on Jan 6, 2021

    @bowenwr
    ContributorAuthor

    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.

  5. dbanty commented on Jan 6, 2021

    @dbanty
    Collaborator

    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.

  6. added this to the 0.8.0 milestone on Jan 16, 2021
  7. dbanty commented on Feb 1, 2021

    @dbanty
    Collaborator

    @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.

  8. emann commented on Feb 1, 2021

    @emann
    Contributor

    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.

  9. forest-benchling commented on Feb 3, 2021

    @forest-benchling
    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?

  10. dbanty commented on Feb 8, 2021

    @dbanty
    Collaborator

    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.

  11. added a commit that references this issue on Feb 8, 2021
    a92d4ce
  12. added a commit that references this issue on Feb 9, 2021
    b2adc2c
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions