From f33bc0a7a5e51a2f007c2a9bf2837563c9568610 Mon Sep 17 00:00:00 2001 From: heather Date: Mon, 20 Jul 2026 13:37:06 +0000 Subject: [PATCH 1/2] Support clearing nullable fields with WorkOS::Null Optional parameters left as nil are omitted from the request body, so a nullable field such as an organization or user external_id could not be cleared through the SDK. Introduce a WorkOS::Null sentinel that serializes to JSON null, letting callers explicitly clear a field. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- README.md | 18 ++++++++++ lib/workos/base_client.rb | 32 +++++++++++++++--- lib/workos/null.rb | 40 ++++++++++++++++++++++ test/workos/test_null.rb | 71 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 157 insertions(+), 4 deletions(-) create mode 100644 lib/workos/null.rb create mode 100644 test/workos/test_null.rb diff --git a/README.md b/README.md index 8365ac0f..bcddb7fa 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,24 @@ organization = WorkOS.client.organizations.get_organization( `Idempotency-Key` is only sent when you provide `request_options[:idempotency_key]`, or when the SDK retries a mutating request after a transient failure. +## Clearing nullable fields + +Optional parameters default to `nil`, and any parameter left as `nil` is +**omitted** from the request so the field is left unchanged. To explicitly +clear a nullable field, pass `WorkOS::Null`, which is serialized as JSON +`null`: + +```ruby +# Leaves external_id unchanged (external_id is omitted from the request): +WorkOS.client.organizations.update_organization(id: "org_123", external_id: nil) + +# Clears external_id (sends {"external_id": null}): +WorkOS.client.organizations.update_organization(id: "org_123", external_id: WorkOS::Null) + +# Works the same way for users: +WorkOS.client.user_management.update_user(id: "user_123", external_id: WorkOS::Null) +``` + ## Usage Examples ### List organizations diff --git a/lib/workos/base_client.rb b/lib/workos/base_client.rb index f688a3c2..18e0f9b1 100644 --- a/lib/workos/base_client.rb +++ b/lib/workos/base_client.rb @@ -66,7 +66,7 @@ def get_request(path:, auth: false, params: {}, request_options: nil) def post_request(path:, auth: false, body: {}, params: {}, request_options: nil) req = build_request(Net::HTTP::Post, append_query(path, params), auth: auth, request_options: request_options) - req.body = body.nil? ? "" : body.compact.to_json + req.body = encode_body(body) req["Content-Type"] = "application/json" inject_idempotency_key(req, request_options) req @@ -75,7 +75,7 @@ def post_request(path:, auth: false, body: {}, params: {}, request_options: nil) def put_request(path:, auth: false, body: {}, params: {}, request_options: nil) req = build_request(Net::HTTP::Put, append_query(path, params), auth: auth, request_options: request_options) - req.body = body.nil? ? "" : body.compact.to_json + req.body = encode_body(body) req["Content-Type"] = "application/json" inject_idempotency_key(req, request_options) req @@ -84,7 +84,7 @@ def put_request(path:, auth: false, body: {}, params: {}, request_options: nil) def patch_request(path:, auth: false, body: {}, params: {}, request_options: nil) req = build_request(Net::HTTP::Patch, append_query(path, params), auth: auth, request_options: request_options) - req.body = body.nil? ? "" : body.compact.to_json + req.body = encode_body(body) req["Content-Type"] = "application/json" inject_idempotency_key(req, request_options) req @@ -94,7 +94,7 @@ def delete_request(path:, auth: false, body: nil, params: {}, request_options: n req = build_request(Net::HTTP::Delete, append_query(path, params), auth: auth, request_options: request_options) if body - req.body = body.compact.to_json + req.body = encode_body(body) req["Content-Type"] = "application/json" end req @@ -180,6 +180,30 @@ def shutdown private + # Serialize a request body to JSON. + # + # Keys whose value is `nil` are omitted entirely (the field is left + # unchanged by the API), while keys explicitly set to {WorkOS::Null} are + # sent as JSON `null` so that nullable fields can be cleared. + def encode_body(body) + return "" if body.nil? + + materialize_nulls(body.compact).to_json + end + + # Recursively replace {WorkOS::Null} sentinels with `nil` so they + # serialize to JSON `null` regardless of the JSON encoder in use. + def materialize_nulls(value) + case value + when Hash + value.each_with_object({}) { |(key, val), acc| acc[key] = materialize_nulls(val) } + when Array + value.map { |val| materialize_nulls(val) } + else + value.equal?(WorkOS::Null) ? nil : value + end + end + # Redact path segments that carry bearer-equivalent tokens (e.g. # `/user_management/invitations/by_token/`, # `/user_management/magic_auth/`, password-reset / email- diff --git a/lib/workos/null.rb b/lib/workos/null.rb new file mode 100644 index 00000000..beac77f5 --- /dev/null +++ b/lib/workos/null.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +# @oagen-ignore-file — hand-maintained runtime + +module WorkOS + # Sentinel value representing an explicit JSON `null` in a request body. + # + # Optional SDK parameters default to `nil`, and any parameter left as `nil` + # is omitted from the request entirely so the corresponding field is left + # unchanged. That makes it impossible to clear a nullable field by passing + # `nil`. Pass {WorkOS::Null} instead to send an explicit `null` and clear + # the field. + # + # @example Clear an organization's external ID + # WorkOS.client.organizations.update_organization( + # id: org.id, + # external_id: WorkOS::Null, + # ) + # + # @example Clear a user's external ID + # WorkOS.client.user_management.update_user( + # id: user.id, + # external_id: WorkOS::Null, + # ) + Null = Object.new + + def Null.to_json(*) + "null" + end + + def Null.as_json(*) + nil + end + + def Null.inspect + "WorkOS::Null" + end + + Null.freeze +end diff --git a/test/workos/test_null.rb b/test/workos/test_null.rb new file mode 100644 index 00000000..33db461a --- /dev/null +++ b/test/workos/test_null.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +# @oagen-ignore-file — hand-maintained runtime + +require "test_helper" + +class NullTest < Minitest::Test + def setup + @client = WorkOS::Client.new(api_key: "sk_test_123") + end + + def test_null_serializes_as_json_null + assert_equal "null", WorkOS::Null.to_json + assert_nil WorkOS::Null.as_json + assert_equal "WorkOS::Null", WorkOS::Null.inspect + end + + def test_nil_param_is_omitted_from_request_body + request = stub_request(:put, "https://api.workos.com/organizations/org_123") + .with { |req| !JSON.parse(req.body).key?("external_id") } + .to_return(body: "{}", status: 200) + + @client.organizations.update_organization(id: "org_123", external_id: nil) + + assert_requested(request) + end + + def test_null_param_clears_field_via_explicit_null + request = stub_request(:put, "https://api.workos.com/organizations/org_123") + .with { |req| JSON.parse(req.body) == {"external_id" => nil} } + .to_return(body: "{}", status: 200) + + @client.organizations.update_organization(id: "org_123", external_id: WorkOS::Null) + + assert_requested(request) + end + + def test_null_param_clears_user_external_id + request = stub_request(:put, "https://api.workos.com/user_management/users/user_123") + .with { |req| JSON.parse(req.body) == {"external_id" => nil} } + .to_return(body: "{}", status: 200) + + @client.user_management.update_user(id: "user_123", external_id: WorkOS::Null) + + assert_requested(request) + end + + def test_null_works_through_raw_request_helper + request = stub_request(:put, "https://api.workos.com/organizations/org_123") + .with { |req| JSON.parse(req.body) == {"external_id" => nil} } + .to_return(body: "{}", status: 200) + + @client.request(method: :put, path: "/organizations/org_123", body: {"external_id" => WorkOS::Null}) + + assert_requested(request) + end + + def test_null_nested_in_hash_serializes_as_null + request = stub_request(:put, "https://api.workos.com/organizations/org_123") + .with { |req| JSON.parse(req.body) == {"metadata" => {"tier" => nil, "plan" => "pro"}} } + .to_return(body: "{}", status: 200) + + @client.request( + method: :put, + path: "/organizations/org_123", + body: {"metadata" => {"tier" => WorkOS::Null, "plan" => "pro"}} + ) + + assert_requested(request) + end +end From f3fcad2f7284258784b8feb8a8640d458215c732 Mon Sep 17 00:00:00 2001 From: heather Date: Mon, 20 Jul 2026 13:44:28 +0000 Subject: [PATCH 2/2] Require workos/null explicitly in base_client Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- lib/workos/base_client.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/workos/base_client.rb b/lib/workos/base_client.rb index 18e0f9b1..ff876d94 100644 --- a/lib/workos/base_client.rb +++ b/lib/workos/base_client.rb @@ -7,6 +7,7 @@ require "securerandom" require "uri" require "workos/errors" +require "workos/null" module WorkOS # Instance-scoped HTTP runtime that implements request execution,