Skip to content

feat: Support clearing nullable fields via explicit None - #693

Closed
devin-ai-integration[bot] wants to merge 2 commits into
mainfrom
devin/1784563931-clear-nullable-oagen
Closed

feat: Support clearing nullable fields via explicit None#693
devin-ai-integration[bot] wants to merge 2 commits into
mainfrom
devin/1784563931-clear-nullable-oagen

Conversation

@devin-ai-integration

Copy link
Copy Markdown
Contributor

Description

Lets callers clear a nullable API field (e.g. an Organization/User external_id) through the normal SDK methods, instead of hand-building a raw request with JSON null.

Previously every optional body param defaulted to None, and the generated method built the body with {k: v ... if v is not None} — so an explicit external_id=None was indistinguishable from an omitted argument and got stripped. There was no way to send JSON null.

Now, nullable optional params default to a NOT_GIVEN sentinel:

  • omit the argument → field is left out of the body (unchanged) — same as before
  • pass an explicit None → field is sent as JSON null (clears it)
  • pass a value → sent as-is
# clears external_id
workos.organizations.update_organization(id="org_123", external_id=None)
workos.user_management.update_user(id="user_123", external_id=None)

# unchanged (omitted)
workos.organizations.update_organization(id="org_123", name="New Name")

Non-nullable optional params keep their exact current behavior (Optional[T] = None, None = omit).

What changed

  • Generated resources (via oagen, PR workos/oagen-emitters#189): nullable optional body params are typed Union[T, None, NotGiven] = NOT_GIVEN; the generated method builds the non-nullable literal with the existing if v is not None filter and conditionally assigns nullable fields with if x is not NOT_GIVEN, so an explicit None survives.
  • src/workos/_types.py (hand-maintained, @oagen-ignore-file): adds the NotGiven sentinel type and NOT_GIVEN instance imported by generated modules.
  • No base-client change needed: _base_client.request passes json=body straight to httpx, so an explicit None in the body serializes to JSON null.

Scope is limited to fields marked nullable in the OpenAPI spec (type: [string, 'null']), matching what the API allows to be cleared. Applies to both sync and async clients.

Part of the cross-SDK effort (Ruby workos/workos-ruby#521, Go to follow) to make nullable clearing consistent.

Testing

  • New tests/test_nullable_clearing.py asserts omitted → absent, explicit None → JSON null, and value → value, for Organization and User external_id (sync + async).
  • Full suite green (1387 passed), ruff check/format clean, pyright clean.

Documentation

Does this require changes to the WorkOS Docs? E.g. the API Reference or code snippets need updates.

[ ] Yes

If yes, link a related docs PR and add a docs maintainer as a reviewer. Their approval is required.

Link to Devin session: https://app.devin.ai/sessions/127c630f46c54bc0be571814c75047e8

Nullable optional body params now default to a NOT_GIVEN sentinel.
Omitting an argument leaves the field unchanged; passing an explicit
None sends JSON null to clear it (e.g. Organization/User external_id).
Adds NotGiven/NOT_GIVEN to _types.py.
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author
Original prompt from heather

SYSTEM:
=== BEGIN THREAD HISTORY (in #dse-pre-triage) ===
<most_recent_message>
Heather Faerber (U08CUNLUBT9): @Devin can you update the rubygem based on this feedback?

&gt; Is there a way via the API or rubygem to clear the external_id for an organization (or user)?
&gt;
&gt; In the web UI, we can just delete the field. (It's under Settings | Organization details | Edit details | External ID.)
&gt;
&gt; But when I tried to set it with the rubygem, it doesn't work:
&gt; • nil gets stripped. The request succeeds, but the value doesn't change.
&gt; • "" returns Validation failed
&gt; • Doing the request directly also gets 200, but the value also doesn't change.
&gt;
&gt; ```&gt; org = WorkOS.client.organizations.create_organization(name: "ExtId Clear Test A", external_id: "ext-clear-test-a")
&gt; =&gt; #&lt;WorkOS::Organization object="organization" id="org_01KXT2M2RQYV33VRKJC7CKKNMY" name="ExtId Clear Test A" domains=[] metadata={} external_id="ext-clear-test-a" created_at="2026-07-18T07:39:00.241Z" updated_at="2026-07-18T07:39:00.241Z" allow_profiles_outside_organization=false&gt;
&gt;
&gt; &gt; WorkOS.client.organizations.update_organization(id: org.id, external_id: "")
&gt; (artemis):48:in '&lt;main&gt;': Validation failed (WorkOS::UnprocessableEntityError)
&gt;
&gt; &gt; WorkOS.client.organizations.update_organization(id: org.id, external_id: nil)
&gt; =&gt; `#`&lt;WorkOS::Organization object="organization" id="org_01KXT2M2RQYV33VRKJC7CKKNMY" name="ExtId Clear Test A" domains=[] metadata={} external_id="ext-clear-test-a" created_at="2026-07-18T07:39:00.241Z" updated_at="2026-07-18T07:39:18.505Z" allow_profiles_outside_organization=false&gt;
&gt;
&gt; &gt; WorkOS.client.organizations.get_organization(id: org.id).external_id.inspect
&gt; =&gt; ""ext-clear-test-a""
&gt;
&gt; &gt; WorkOS.client.request(method: :put, path: "/organizations/`#`{org.id}", body: { "external_id" =&gt; nil })
&gt; =&gt; `#`&lt;Net::HTTPOK 200 OK readbody=true&gt;
&gt;
&gt; &gt; Wo... (3272 chars truncated...)

@devin-ai-integration
devin-ai-integration Bot requested review from a team as code owners July 20, 2026 21:50
@devin-ai-integration
devin-ai-integration Bot requested a review from nicknisi July 20, 2026 21:50
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@workos-sdk-automation

Copy link
Copy Markdown
Contributor

🤖 This pull request was closed automatically

It edits files that are auto-generated by (each file has a header comment identifying it as generated). Hand edits to generated code are overwritten the next time the SDK is regenerated from the OpenAPI spec, so they can't be merged.

Generated files changed outside their hand-maintainable regions:

  • src/workos/api_keys/_resource.py
  • src/workos/authorization/_resource.py
  • src/workos/connect/_resource.py
  • src/workos/groups/_resource.py
  • src/workos/organizations/_resource.py
  • src/workos/pipes/_resource.py
  • src/workos/pipes_provider/_resource.py
  • src/workos/user_management/_resource.py
  • src/workos/vault/_resource.py

What to do instead

  • Generated code (models, resources, client wiring): make the change upstream in the OpenAPI spec so it lands on the next regeneration.
  • Hand-maintained code inside a generated file: only the regions fenced by @oagen-ignore-start@oagen-ignore-end may be edited by hand. Keep your changes within those fences.

If you believe this was closed in error, a maintainer can reopen the PR.

@greptile-apps

greptile-apps Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR introduces a NotGiven sentinel type to distinguish an omitted nullable argument from an explicit None, enabling callers to send JSON null to clear nullable API fields (e.g. external_id, metadata, expires_at) through the normal SDK methods. The change is driven by the OpenAPI spec — only fields marked type: [T, 'null'] receive the sentinel treatment — and is consistent with the same pattern shipped in the Ruby SDK.

  • src/workos/_types.py: Adds NotGiven class (falsy, __bool__ returns Literal[False] for type-narrowing) and the module-level NOT_GIVEN singleton imported by generated resource modules.
  • Resource files (api_keys, authorization, connect, groups, organizations, pipes, pipes_provider, user_management, vault): Nullable optional params switch from Optional[T] = None to Union[T, None, NotGiven] = NOT_GIVEN; body-building changes from the if v is not None comprehension to explicit if x is not NOT_GIVEN guards so explicit None survives into the JSON body.
  • tests/test_nullable_clearing.py: New tests assert the three-state semantics (omit → absent, None → JSON null, value → value) for Organization and User external_id, plus a sentinel-leak guard confirming NOT_GIVEN never appears in the serialized request body.

Confidence Score: 5/5

Safe to merge — the sentinel is only applied to fields the OpenAPI spec marks nullable, the identity-check guards are consistent across all resources, and existing callers who omit these params see no behavior change.

Every changed field has an is not NOT_GIVEN guard that correctly distinguishes omit from explicit None; the __bool__ returning Literal[False] enables type-narrowing without affecting runtime logic; required params are moved out of comprehensions into direct dict literals correctly; and the three-state semantics are verified by the new test suite including a sentinel-leak assertion.

No files require special attention.

Important Files Changed

Filename Overview
src/workos/_types.py Adds NotGiven sentinel class and NOT_GIVEN singleton; __bool__ returning Literal[False] enables correct type-narrowing and falsy checks; implementation is clean and follows established SDK patterns.
src/workos/organizations/_resource.py Converts metadata and external_id to NOT_GIVEN-defaulted params in both create_organization and update_organization (sync + async); body-building correctly uses is not NOT_GIVEN identity checks.
src/workos/user_management/_resource.py Converts 8 create_user params and 3 update_user params (metadata, external_id, locale) to the sentinel pattern; non-nullable fields like first_name remain Optional[str] = None in update_user, correctly reflecting the spec asymmetry.
src/workos/api_keys/_resource.py Converts expires_at to NOT_GIVEN-sentinel in expire_api_key; enables the three meaningful states (omit → immediate expiry, None → clear scheduled expiry, value → schedule expiry) that the spec documents.
src/workos/authorization/_resource.py Converts description to the sentinel pattern across many role/permission/resource create and update methods (sync + async); required fields like name, slug, organization_id are correctly moved out of the if v is not None comprehension and into the direct dict literal.
src/workos/connect/_resource.py Converts description, scopes, and redirect_uris to the sentinel pattern in update_application; the redirect_uris branch correctly guards the item.to_dict() iteration behind a secondary is not None check to avoid iterating over an explicit null.
src/workos/pipes/_resource.py Converts description, scopes, and organization_id to the sentinel pattern across pipe create/update and token-fetch methods (sync + async); logic is consistent with other resources.
src/workos/groups/_resource.py Converts description to the sentinel pattern in both create_group and update_group; required name field is now always included directly rather than filtered via comprehension, which is correct.
src/workos/vault/_resource.py Converts version_check to the sentinel pattern in update_object; required value field moves out of the comprehension into the direct dict literal.
src/workos/pipes_provider/_resource.py Converts scopes to the sentinel pattern in the provider configuration update method.
tests/test_nullable_clearing.py New test module covering omit/null/value semantics for org and user external_id, plus a sentinel-leak guard asserting NOT_GIVEN never appears in the serialized request body.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["Caller passes nullable param"] --> B{Argument provided?}
    B -- "Omitted (default)" --> C["Value = NOT_GIVEN"]
    B -- "Explicit None" --> D["Value = None"]
    B -- "Concrete value" --> E["Value = str / list / etc."]

    C --> F{"if x is not NOT_GIVEN?"}
    D --> F
    E --> F

    F -- "False (NOT_GIVEN)" --> G["Field excluded from body\n(unchanged on server)"]
    F -- "True (None or value)" --> H["body[field] = value"]

    H --> I{value is None?}
    I -- "Yes" --> J["JSON: null\n(clears the field)"]
    I -- "No" --> K["JSON: concrete value\n(sets the field)"]

    G --> L["httpx sends body"]
    J --> L
    K --> L
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A["Caller passes nullable param"] --> B{Argument provided?}
    B -- "Omitted (default)" --> C["Value = NOT_GIVEN"]
    B -- "Explicit None" --> D["Value = None"]
    B -- "Concrete value" --> E["Value = str / list / etc."]

    C --> F{"if x is not NOT_GIVEN?"}
    D --> F
    E --> F

    F -- "False (NOT_GIVEN)" --> G["Field excluded from body\n(unchanged on server)"]
    F -- "True (None or value)" --> H["body[field] = value"]

    H --> I{value is None?}
    I -- "Yes" --> J["JSON: null\n(clears the field)"]
    I -- "No" --> K["JSON: concrete value\n(sets the field)"]

    G --> L["httpx sends body"]
    J --> L
    K --> L
Loading

Reviews (2): Last reviewed commit: "test: Assert NOT_GIVEN sentinel never le..." | Re-trigger Greptile

Comment thread src/workos/user_management/_resource.py
Comment thread src/workos/api_keys/_resource.py
Comment thread tests/test_nullable_clearing.py
@workos-sdk-automation

Copy link
Copy Markdown
Contributor

🤖 This pull request was closed automatically

It edits files that are auto-generated by (each file has a header comment identifying it as generated). Hand edits to generated code are overwritten the next time the SDK is regenerated from the OpenAPI spec, so they can't be merged.

Generated files changed outside their hand-maintainable regions:

  • src/workos/api_keys/_resource.py
  • src/workos/authorization/_resource.py
  • src/workos/connect/_resource.py
  • src/workos/groups/_resource.py
  • src/workos/organizations/_resource.py
  • src/workos/pipes/_resource.py
  • src/workos/pipes_provider/_resource.py
  • src/workos/user_management/_resource.py
  • src/workos/vault/_resource.py

What to do instead

  • Generated code (models, resources, client wiring): make the change upstream in the OpenAPI spec so it lands on the next regeneration.
  • Hand-maintained code inside a generated file: only the regions fenced by @oagen-ignore-start@oagen-ignore-end may be edited by hand. Keep your changes within those fences.

If you believe this was closed in error, a maintainer can reopen the PR.

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

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants