Skip to content

feat: Support clearing nullable fields via NullFields - #585

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

feat: Support clearing nullable fields via NullFields#585
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 params structs, instead of hand-building a raw request with JSON null.

Go can't express the difference between "absent", "null", and "value" with a plain *string + omitempty: a nil pointer is omitted, and &"" sends an empty string (which the API rejects with 422). There was no way to send JSON null.

This adds an additive, non-breaking mechanism (Google-API-client style): params structs with nullable optional fields gain a NullFields []string and a generated MarshalJSON. Existing pointer fields are unchanged.

// clears external_id
params := workos.OrganizationsUpdateParams{
    NullFields: []string{"external_id"},
}
// marshals {"external_id": null}

Semantics:

  • nil pointer not in NullFields → omitted (unchanged) — same as before
  • non-nil pointer → concrete JSON value — same as before
  • field name in NullFields → explicit JSON null (clears it)
  • NullFields itself never appears in the JSON output

Validation

MarshalJSON validates each NullFields entry against the struct's actual nullable field names (an allowlist generated per struct). An unknown, typoed, or non-nullable entry returns a marshal error instead of silently injecting a bogus "field": null.

What changed

All changes are oagen-generated (PR workos/oagen-emitters#189) — no hand-maintained runtime file is required. Applied to both regular params structs and hidden-params body structs. The encoding/json/fmt imports are pulled in only where a clearable field exists.

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

Testing

  • New nullable_clearing_test.go covers concrete value, omitted nil pointer, explicit null, invalid NullFields entry (error), and that NullFields is never serialized, for Organization and User external_id.
  • go build ./..., go vet ./..., and full go test ./... all green; gofmt 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

Params structs with nullable optional fields gain an additive
NullFields []string and a generated MarshalJSON. Listing a field's
JSON name in NullFields sends it as explicit JSON null to clear it
(e.g. NullFields: []string{"external_id"}). A nil pointer not listed
stays omitted; entries are validated against the struct's nullable
fields and an unknown name returns a marshal error. Non-breaking.
@devin-ai-integration
devin-ai-integration Bot requested review from a team as code owners July 20, 2026 21:54
@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

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:

  • api_keys.go
  • authorization.go
  • connect.go
  • groups.go
  • organizations.go
  • pipes.go
  • pipes_provider.go
  • user_management.go
  • vault.go

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.

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

  • api_keys.go
  • authorization.go
  • connect.go
  • groups.go
  • organizations.go
  • pipes.go
  • pipes_provider.go
  • user_management.go
  • vault.go

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 Google-API-client-style NullFields []string field and a generated MarshalJSON on every params struct that has at least one optional (nullable) API field, giving callers a way to send an explicit JSON null — something Go's *T + omitempty cannot express natively. The mechanism uses a per-struct allowlist to validate entries at marshal time, so typos and non-nullable field names are caught immediately.

  • Additive and non-breaking: existing pointer fields and omitempty behaviour are unchanged; NullFields is tagged json:\"-\" so it never leaks into the wire format.
  • Consistent pattern across 10 files: structs with a pre-existing custom MarshalJSON (e.g. for Password or ParentResource) are correctly extended rather than replaced.
  • Two issues to address: (1) the NullFields doc comment copies e.g. []string{\"external_id\"} verbatim to every struct, including ones where that entry would cause a runtime marshal error; (2) setting a concrete field value and listing the same field in NullFields silently discards the concrete value with no error.

Confidence Score: 3/5

Merging carries a real data-loss risk: a caller who sets a field value and mistakenly also lists that field in NullFields will silently clear the value at the API instead of setting it, with no error surfaced.

The MarshalJSON pattern is mechanically sound across all ten files, but the silent-override behaviour — where a concrete field value is overwritten by a NullFields entry without any validation error — creates a footgun that can cause unintended data clearing in production. Combined with doc comments that advertise an invalid example field name for most structs, these two issues together warrant attention before merging.

organizations.go and user_management.go are the most likely targets for the concurrent-set-and-null mistake; nullable_clearing_test.go needs a test case for that scenario; every changed file needs its NullFields comment example updated to a field name actually valid for that struct.

Important Files Changed

Filename Overview
api_keys.go Adds NullFields + MarshalJSON to APIKeysCreateExpireParams; NullFields comment incorrectly uses "external_id" as the example despite only "expires_at" being valid.
authorization.go Adds NullFields to 6 params structs; existing custom MarshalJSON methods correctly extended; misleading "external_id" example in all new comments.
organizations.go Adds NullFields to OrganizationsCreateParams and OrganizationsUpdateParams; concrete ExternalID value paired with NullFields["external_id"] is silently discarded.
user_management.go Extends the two existing Password-aware MarshalJSON methods with NullFields support; nullable list for Create includes 8 fields, broader than Update — presumably matches the API schema.
nullable_clearing_test.go New test file with good coverage; missing a test for the concurrent set-and-null conflict scenario.
connect.go Adds NullFields to ConnectUpdateApplicationParams with allowlist {description, scopes, redirect_uris}; mechanically correct.
groups.go Adds NullFields to two Groups params structs with "description" as the only nullable field; mechanically correct.
pipes.go Adds NullFields to three Pipes params structs; mechanically correct.
pipes_provider.go Adds NullFields to PipesProviderUpdateOrganizationDataIntegrationConfigurationParams with only "scopes" as nullable; mechanically correct.
vault.go Adds NullFields to VaultUpdateKvParams with only "version_check" as nullable; mechanically correct.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["caller marshals params struct"] --> B{"len(NullFields) == 0?"}
    B -- yes --> C["return standard json.Marshal(Alias(p))\n(omitempty rules apply normally)"]
    B -- no --> D["json.Marshal(Alias(p)) -> raw bytes"]
    D --> E["json.Unmarshal raw bytes -> map[string]any"]
    E --> F{"extra side effects?\ne.g. Password, ParentResource"}
    F -- yes --> G["applyToBody(m)"]
    F -- no --> H["validate + apply NullFields"]
    G --> H
    H --> I{"field in allowlist?"}
    I -- no --> J["return error: field is not nullable"]
    I -- yes --> K["m[field] = nil (explicit JSON null)"]
    K --> L{"more NullFields entries?"}
    L -- yes --> I
    L -- no --> M["json.Marshal(m) - final output"]
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 marshals params struct"] --> B{"len(NullFields) == 0?"}
    B -- yes --> C["return standard json.Marshal(Alias(p))\n(omitempty rules apply normally)"]
    B -- no --> D["json.Marshal(Alias(p)) -> raw bytes"]
    D --> E["json.Unmarshal raw bytes -> map[string]any"]
    E --> F{"extra side effects?\ne.g. Password, ParentResource"}
    F -- yes --> G["applyToBody(m)"]
    F -- no --> H["validate + apply NullFields"]
    G --> H
    H --> I{"field in allowlist?"}
    I -- no --> J["return error: field is not nullable"]
    I -- yes --> K["m[field] = nil (explicit JSON null)"]
    K --> L{"more NullFields entries?"}
    L -- yes --> I
    L -- no --> M["json.Marshal(m) - final output"]
Loading

Comments Outside Diff (1)

  1. api_keys.go, line 17-19 (link)

    P2 Misleading NullFields comment example across all files

    The copy-pasted doc comment uses e.g. []string{"external_id"} in every struct, including ones where external_id is not a valid nullable field. For APIKeysCreateExpireParams only "expires_at" is accepted — passing "external_id" would return a marshal error at runtime. The same problem appears in AuthorizationCreateOrganizationRoleParams, GroupsCreateOrganizationGroupParams, PipesProviderUpdateOrganizationDataIntegrationConfigurationParams, VaultUpdateKvParams, and others throughout the PR. Each struct's comment should reference one of its own actual nullable field names, or the allowlist should be stated explicitly.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: api_keys.go
    Line: 17-19
    
    Comment:
    **Misleading `NullFields` comment example across all files**
    
    The copy-pasted doc comment uses `e.g. []string{"external_id"}` in every struct, including ones where `external_id` is not a valid nullable field. For `APIKeysCreateExpireParams` only `"expires_at"` is accepted — passing `"external_id"` would return a marshal error at runtime. The same problem appears in `AuthorizationCreateOrganizationRoleParams`, `GroupsCreateOrganizationGroupParams`, `PipesProviderUpdateOrganizationDataIntegrationConfigurationParams`, `VaultUpdateKvParams`, and others throughout the PR. Each struct's comment should reference one of its own actual nullable field names, or the allowlist should be stated explicitly.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
api_keys.go:17-19
**Misleading `NullFields` comment example across all files**

The copy-pasted doc comment uses `e.g. []string{"external_id"}` in every struct, including ones where `external_id` is not a valid nullable field. For `APIKeysCreateExpireParams` only `"expires_at"` is accepted — passing `"external_id"` would return a marshal error at runtime. The same problem appears in `AuthorizationCreateOrganizationRoleParams`, `GroupsCreateOrganizationGroupParams`, `PipesProviderUpdateOrganizationDataIntegrationConfigurationParams`, `VaultUpdateKvParams`, and others throughout the PR. Each struct's comment should reference one of its own actual nullable field names, or the allowlist should be stated explicitly.

### Issue 2 of 3
organizations.go:69-74
**Concrete field value silently discarded when field is also in `NullFields`**

If a caller sets, say, `ExternalID: workos.String("foo")` and also includes `"external_id"` in `NullFields`, the MarshalJSON will first serialize the concrete value, then overwrite it with `null` in the map before the final marshal — sending `{"external_id": null}` to the API. The concrete value is silently lost with no error. This same pattern is replicated across every struct in the PR. A guard that detects this contradictory state and returns an error would prevent unintended data clearing.

### Issue 3 of 3
nullable_clearing_test.go:1-80
**No test for the concurrent set-and-null conflict case**

The suite covers concrete value, omitted nil, explicit null, invalid field name, and NullFields-not-leaked. It does not test the case where a caller sets a non-nil field value AND includes that same field name in `NullFields` simultaneously (e.g. `ExternalID: workos.String("x"), NullFields: []string{"external_id"}`). Given that this combination silently discards the concrete value, a test documenting the resulting behaviour (or the desired validation error) would protect against accidental regression.

Reviews (1): Last reviewed commit: "test: Assert NullFields is never seriali..." | Re-trigger Greptile

Comment thread organizations.go
Comment on lines +69 to +74
for _, f := range p.NullFields {
if !nullable[f] {
return nil, fmt.Errorf("OrganizationsCreateParams: %q is not a nullable field", f)
}
m[f] = nil
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Concrete field value silently discarded when field is also in NullFields

If a caller sets, say, ExternalID: workos.String("foo") and also includes "external_id" in NullFields, the MarshalJSON will first serialize the concrete value, then overwrite it with null in the map before the final marshal — sending {"external_id": null} to the API. The concrete value is silently lost with no error. This same pattern is replicated across every struct in the PR. A guard that detects this contradictory state and returns an error would prevent unintended data clearing.

Prompt To Fix With AI
This is a comment left during a code review.
Path: organizations.go
Line: 69-74

Comment:
**Concrete field value silently discarded when field is also in `NullFields`**

If a caller sets, say, `ExternalID: workos.String("foo")` and also includes `"external_id"` in `NullFields`, the MarshalJSON will first serialize the concrete value, then overwrite it with `null` in the map before the final marshal — sending `{"external_id": null}` to the API. The concrete value is silently lost with no error. This same pattern is replicated across every struct in the PR. A guard that detects this contradictory state and returns an error would prevent unintended data clearing.

How can I resolve this? If you propose a fix, please make it concise.

Comment thread nullable_clearing_test.go
Comment on lines +1 to +80
package workos_test

import (
"encoding/json"
"testing"

"github.com/stretchr/testify/require"
"github.com/workos/workos-go/v9"
)

// These tests verify the oagen-generated NullFields / MarshalJSON behavior for
// clearing nullable fields:
// - a concrete pointer serializes as its value
// - a nil pointer not listed in NullFields is omitted
// - a field listed in NullFields serializes as explicit JSON null
// - an unknown / non-nullable NullFields entry returns a marshal error
// - NullFields itself never appears in the JSON output

func TestNullableClearing_ConcreteValue(t *testing.T) {
data, err := json.Marshal(workos.OrganizationsUpdateParams{
ExternalID: workos.String("ext-123"),
})
require.NoError(t, err)

var m map[string]any
require.NoError(t, json.Unmarshal(data, &m))
require.Equal(t, "ext-123", m["external_id"])
require.NotContains(t, m, "NullFields")
}

func TestNullableClearing_OmittedNilPointer(t *testing.T) {
data, err := json.Marshal(workos.OrganizationsUpdateParams{})
require.NoError(t, err)

var m map[string]any
require.NoError(t, json.Unmarshal(data, &m))
require.NotContains(t, m, "external_id")
require.NotContains(t, m, "NullFields")
}

func TestNullableClearing_ExplicitNull(t *testing.T) {
data, err := json.Marshal(workos.OrganizationsUpdateParams{
NullFields: []string{"external_id"},
})
require.NoError(t, err)

var m map[string]any
require.NoError(t, json.Unmarshal(data, &m))
require.Contains(t, m, "external_id")
require.Nil(t, m["external_id"])
require.NotContains(t, m, "NullFields")
}

func TestNullableClearing_InvalidField(t *testing.T) {
_, err := json.Marshal(workos.OrganizationsUpdateParams{
NullFields: []string{"not_a_real_field"},
})
require.Error(t, err)
}

func TestNullableClearing_SentinelNeverLeaks(t *testing.T) {
data, err := json.Marshal(workos.OrganizationsUpdateParams{
Name: workos.String("New Name"),
NullFields: []string{"external_id"},
})
require.NoError(t, err)
require.NotContains(t, string(data), "NullFields")
}

func TestNullableClearing_User(t *testing.T) {
data, err := json.Marshal(workos.UserManagementUpdateParams{
NullFields: []string{"external_id"},
})
require.NoError(t, err)

var m map[string]any
require.NoError(t, json.Unmarshal(data, &m))
require.Contains(t, m, "external_id")
require.Nil(t, m["external_id"])
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 No test for the concurrent set-and-null conflict case

The suite covers concrete value, omitted nil, explicit null, invalid field name, and NullFields-not-leaked. It does not test the case where a caller sets a non-nil field value AND includes that same field name in NullFields simultaneously (e.g. ExternalID: workos.String("x"), NullFields: []string{"external_id"}). Given that this combination silently discards the concrete value, a test documenting the resulting behaviour (or the desired validation error) would protect against accidental regression.

Prompt To Fix With AI
This is a comment left during a code review.
Path: nullable_clearing_test.go
Line: 1-80

Comment:
**No test for the concurrent set-and-null conflict case**

The suite covers concrete value, omitted nil, explicit null, invalid field name, and NullFields-not-leaked. It does not test the case where a caller sets a non-nil field value AND includes that same field name in `NullFields` simultaneously (e.g. `ExternalID: workos.String("x"), NullFields: []string{"external_id"}`). Given that this combination silently discards the concrete value, a test documenting the resulting behaviour (or the desired validation error) would protect against accidental regression.

How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

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