From a6ef417c749468b6e6a6caeb77c08ca6fd2430cb Mon Sep 17 00:00:00 2001 From: Devin Date: Mon, 20 Jul 2026 21:44:01 +0000 Subject: [PATCH 1/2] feat: Support clearing nullable fields via explicit nil Nullable optional body params now default to a generated WorkOS::OMIT sentinel. Omitting an argument leaves the field unchanged; passing an explicit nil sends JSON null to clear it (e.g. Organization/User external_id). base_client no longer .compact-s the request body so intentional nils survive serialization. --- lib/workos.rb | 10 ++++ lib/workos/api_keys.rb | 7 ++- lib/workos/authorization.rb | 48 +++++++++---------- lib/workos/base_client.rb | 8 ++-- lib/workos/connect.rb | 36 +++++++------- lib/workos/groups.rb | 14 +++--- lib/workos/organizations.rb | 20 ++++---- lib/workos/pipes.rb | 26 +++++----- lib/workos/pipes_provider.rb | 4 +- lib/workos/user_management.rb | 46 +++++++++--------- lib/workos/vault.rb | 8 ++-- test/workos/test_nullable_clearing.rb | 69 +++++++++++++++++++++++++++ 12 files changed, 188 insertions(+), 108 deletions(-) create mode 100644 test/workos/test_nullable_clearing.rb diff --git a/lib/workos.rb b/lib/workos.rb index a231c1e9..b2d03b85 100644 --- a/lib/workos.rb +++ b/lib/workos.rb @@ -5,6 +5,16 @@ require "zeitwerk" module WorkOS + # Sentinel default for nullable optional parameters. Distinguishes an + # omitted argument ("leave unchanged") from an explicit `nil`, which + # clears the field by sending JSON `null`. + OMIT = Object.new + + def OMIT.inspect + "WorkOS::OMIT" + end + + OMIT.freeze end loader = Zeitwerk::Loader.for_gem diff --git a/lib/workos/api_keys.rb b/lib/workos/api_keys.rb index aaf22c61..e4d61092 100644 --- a/lib/workos/api_keys.rb +++ b/lib/workos/api_keys.rb @@ -135,12 +135,11 @@ def delete_api_key( # @return [WorkOS::ApiKey] def create_api_key_expire( id:, - expires_at: nil, + expires_at: WorkOS::OMIT, request_options: {} ) - body = { - "expires_at" => expires_at - }.compact + body = {} + body["expires_at"] = expires_at unless expires_at.equal?(WorkOS::OMIT) response = @client.request( method: :post, path: "/api_keys/#{WorkOS::Util.encode_path(id)}/expire", diff --git a/lib/workos/authorization.rb b/lib/workos/authorization.rb index b2c72262..2a59903c 100644 --- a/lib/workos/authorization.rb +++ b/lib/workos/authorization.rb @@ -615,16 +615,16 @@ def create_organization_role( organization_id:, name:, slug: nil, - description: nil, + description: WorkOS::OMIT, resource_type_slug: nil, request_options: {} ) body = { "slug" => slug, "name" => name, - "description" => description, "resource_type_slug" => resource_type_slug }.compact + body["description"] = description unless description.equal?(WorkOS::OMIT) response = @client.request( method: :post, path: "/authorization/organizations/#{WorkOS::Util.encode_path(organization_id)}/roles", @@ -669,13 +669,13 @@ def update_organization_role( organization_id:, slug:, name: nil, - description: nil, + description: WorkOS::OMIT, request_options: {} ) body = { - "name" => name, - "description" => description + "name" => name }.compact + body["description"] = description unless description.equal?(WorkOS::OMIT) response = @client.request( method: :patch, path: "/authorization/organizations/#{WorkOS::Util.encode_path(organization_id)}/roles/#{WorkOS::Util.encode_path(slug)}", @@ -821,14 +821,14 @@ def update_resource_by_external_id( resource_type_slug:, external_id:, name: nil, - description: nil, + description: WorkOS::OMIT, parent_resource: nil, request_options: {} ) body = { - "name" => name, - "description" => description + "name" => name }.compact + body["description"] = description unless description.equal?(WorkOS::OMIT) if parent_resource case parent_resource when WorkOS::Authorization::ParentResourceById @@ -1081,17 +1081,17 @@ def create_resource( name:, resource_type_slug:, organization_id:, - description: nil, + description: WorkOS::OMIT, parent_resource: nil, request_options: {} ) body = { "external_id" => external_id, "name" => name, - "description" => description, "resource_type_slug" => resource_type_slug, "organization_id" => organization_id - }.compact + } + body["description"] = description unless description.equal?(WorkOS::OMIT) if parent_resource case parent_resource when WorkOS::Authorization::ParentResourceById @@ -1144,14 +1144,14 @@ def get_resource( def update_resource( resource_id:, name: nil, - description: nil, + description: WorkOS::OMIT, parent_resource: nil, request_options: {} ) body = { - "name" => name, - "description" => description + "name" => name }.compact + body["description"] = description unless description.equal?(WorkOS::OMIT) if parent_resource case parent_resource when WorkOS::Authorization::ParentResourceById @@ -1329,16 +1329,16 @@ def list_environment_roles(request_options: {}) def create_environment_role( slug:, name:, - description: nil, + description: WorkOS::OMIT, resource_type_slug: nil, request_options: {} ) body = { "slug" => slug, "name" => name, - "description" => description, "resource_type_slug" => resource_type_slug }.compact + body["description"] = description unless description.equal?(WorkOS::OMIT) response = @client.request( method: :post, path: "/authorization/roles", @@ -1379,13 +1379,13 @@ def get_environment_role( def update_environment_role( slug:, name: nil, - description: nil, + description: WorkOS::OMIT, request_options: {} ) body = { - "name" => name, - "description" => description + "name" => name }.compact + body["description"] = description unless description.equal?(WorkOS::OMIT) response = @client.request( method: :patch, path: "/authorization/roles/#{WorkOS::Util.encode_path(slug)}", @@ -1502,16 +1502,16 @@ def list_permissions( def create_permission( slug:, name:, - description: nil, + description: WorkOS::OMIT, resource_type_slug: nil, request_options: {} ) body = { "slug" => slug, "name" => name, - "description" => description, "resource_type_slug" => resource_type_slug }.compact + body["description"] = description unless description.equal?(WorkOS::OMIT) response = @client.request( method: :post, path: "/authorization/permissions", @@ -1552,13 +1552,13 @@ def get_permission( def update_permission( slug:, name: nil, - description: nil, + description: WorkOS::OMIT, request_options: {} ) body = { - "name" => name, - "description" => description + "name" => name }.compact + body["description"] = description unless description.equal?(WorkOS::OMIT) response = @client.request( method: :patch, path: "/authorization/permissions/#{WorkOS::Util.encode_path(slug)}", diff --git a/lib/workos/base_client.rb b/lib/workos/base_client.rb index f688a3c2..7fcc6522 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 = body.nil? ? "" : body.to_json 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 = body.nil? ? "" : body.to_json 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 = body.nil? ? "" : body.to_json 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 = body.to_json req["Content-Type"] = "application/json" end req diff --git a/lib/workos/connect.rb b/lib/workos/connect.rb index 2a6d5214..dd735bbc 100644 --- a/lib/workos/connect.rb +++ b/lib/workos/connect.rb @@ -101,24 +101,24 @@ def list_applications( def create_application( name:, application_type:, - description: nil, - scopes: nil, - redirect_uris: nil, - uses_pkce: nil, + description: WorkOS::OMIT, + scopes: WorkOS::OMIT, + redirect_uris: WorkOS::OMIT, + uses_pkce: WorkOS::OMIT, is_first_party: nil, - organization_id: nil, + organization_id: WorkOS::OMIT, request_options: {} ) body = { "name" => name, "application_type" => application_type, - "description" => description, - "scopes" => scopes, - "redirect_uris" => redirect_uris, - "uses_pkce" => uses_pkce, - "is_first_party" => is_first_party, - "organization_id" => organization_id + "is_first_party" => is_first_party }.compact + body["description"] = description unless description.equal?(WorkOS::OMIT) + body["scopes"] = scopes unless scopes.equal?(WorkOS::OMIT) + body["redirect_uris"] = redirect_uris unless redirect_uris.equal?(WorkOS::OMIT) + body["uses_pkce"] = uses_pkce unless uses_pkce.equal?(WorkOS::OMIT) + body["organization_id"] = organization_id unless organization_id.equal?(WorkOS::OMIT) response = @client.request( method: :post, path: "/connect/applications", @@ -232,17 +232,17 @@ def get_application( def update_application( id:, name: nil, - description: nil, - scopes: nil, - redirect_uris: nil, + description: WorkOS::OMIT, + scopes: WorkOS::OMIT, + redirect_uris: WorkOS::OMIT, request_options: {} ) body = { - "name" => name, - "description" => description, - "scopes" => scopes, - "redirect_uris" => redirect_uris + "name" => name }.compact + body["description"] = description unless description.equal?(WorkOS::OMIT) + body["scopes"] = scopes unless scopes.equal?(WorkOS::OMIT) + body["redirect_uris"] = redirect_uris unless redirect_uris.equal?(WorkOS::OMIT) response = @client.request( method: :put, path: "/connect/applications/#{WorkOS::Util.encode_path(id)}", diff --git a/lib/workos/groups.rb b/lib/workos/groups.rb index de6660ee..32847777 100644 --- a/lib/workos/groups.rb +++ b/lib/workos/groups.rb @@ -66,13 +66,13 @@ def list_organization_groups( def create_organization_group( organization_id:, name:, - description: nil, + description: WorkOS::OMIT, request_options: {} ) body = { - "name" => name, - "description" => description - }.compact + "name" => name + } + body["description"] = description unless description.equal?(WorkOS::OMIT) response = @client.request( method: :post, path: "/organizations/#{WorkOS::Util.encode_path(organization_id)}/groups", @@ -117,13 +117,13 @@ def update_organization_group( organization_id:, group_id:, name: nil, - description: nil, + description: WorkOS::OMIT, request_options: {} ) body = { - "name" => name, - "description" => description + "name" => name }.compact + body["description"] = description unless description.equal?(WorkOS::OMIT) response = @client.request( method: :patch, path: "/organizations/#{WorkOS::Util.encode_path(organization_id)}/groups/#{WorkOS::Util.encode_path(group_id)}", diff --git a/lib/workos/organizations.rb b/lib/workos/organizations.rb index 8a595b52..bac3995d 100644 --- a/lib/workos/organizations.rb +++ b/lib/workos/organizations.rb @@ -76,18 +76,18 @@ def create_organization( allow_profiles_outside_organization: nil, domains: nil, domain_data: nil, - metadata: nil, - external_id: nil, + metadata: WorkOS::OMIT, + external_id: WorkOS::OMIT, request_options: {} ) body = { "name" => name, "allow_profiles_outside_organization" => allow_profiles_outside_organization, "domains" => domains, - "domain_data" => domain_data, - "metadata" => metadata, - "external_id" => external_id + "domain_data" => domain_data }.compact + body["metadata"] = metadata unless metadata.equal?(WorkOS::OMIT) + body["external_id"] = external_id unless external_id.equal?(WorkOS::OMIT) response = @client.request( method: :post, path: "/organizations", @@ -156,8 +156,8 @@ def update_organization( domains: nil, domain_data: nil, stripe_customer_id: nil, - metadata: nil, - external_id: nil, + metadata: WorkOS::OMIT, + external_id: WorkOS::OMIT, request_options: {} ) body = { @@ -165,10 +165,10 @@ def update_organization( "allow_profiles_outside_organization" => allow_profiles_outside_organization, "domains" => domains, "domain_data" => domain_data, - "stripe_customer_id" => stripe_customer_id, - "metadata" => metadata, - "external_id" => external_id + "stripe_customer_id" => stripe_customer_id }.compact + body["metadata"] = metadata unless metadata.equal?(WorkOS::OMIT) + body["external_id"] = external_id unless external_id.equal?(WorkOS::OMIT) response = @client.request( method: :put, path: "/organizations/#{WorkOS::Util.encode_path(id)}", diff --git a/lib/workos/pipes.rb b/lib/workos/pipes.rb index 1a211e5e..14e309c6 100644 --- a/lib/workos/pipes.rb +++ b/lib/workos/pipes.rb @@ -65,21 +65,23 @@ def list_data_integrations( # @return [WorkOS::DataIntegration] def create_data_integration( provider:, - description: nil, + description: WorkOS::OMIT, enabled: nil, - scopes: nil, + scopes: WorkOS::OMIT, + auth_methods: nil, credentials: nil, custom_provider: nil, request_options: {} ) body = { "provider" => provider, - "description" => description, "enabled" => enabled, - "scopes" => scopes, + "auth_methods" => auth_methods, "credentials" => credentials, "custom_provider" => custom_provider }.compact + body["description"] = description unless description.equal?(WorkOS::OMIT) + body["scopes"] = scopes unless scopes.equal?(WorkOS::OMIT) response = @client.request( method: :post, path: "/data-integrations", @@ -122,20 +124,20 @@ def get_data_integration( # @return [WorkOS::DataIntegration] def update_data_integration( slug:, - description: nil, + description: WorkOS::OMIT, enabled: nil, - scopes: nil, + scopes: WorkOS::OMIT, credentials: nil, custom_provider: nil, request_options: {} ) body = { - "description" => description, "enabled" => enabled, - "scopes" => scopes, "credentials" => credentials, "custom_provider" => custom_provider }.compact + body["description"] = description unless description.equal?(WorkOS::OMIT) + body["scopes"] = scopes unless scopes.equal?(WorkOS::OMIT) response = @client.request( method: :put, path: "/data-integrations/#{WorkOS::Util.encode_path(slug)}", @@ -264,13 +266,13 @@ def create_data_integration_credential( def get_access_token( provider:, user_id:, - organization_id: nil, + organization_id: WorkOS::OMIT, request_options: {} ) body = { - "user_id" => user_id, - "organization_id" => organization_id - }.compact + "user_id" => user_id + } + body["organization_id"] = organization_id unless organization_id.equal?(WorkOS::OMIT) response = @client.request( method: :post, path: "/data-integrations/#{WorkOS::Util.encode_path(provider)}/token", diff --git a/lib/workos/pipes_provider.rb b/lib/workos/pipes_provider.rb index 4bf28dba..edecdfec 100644 --- a/lib/workos/pipes_provider.rb +++ b/lib/workos/pipes_provider.rb @@ -42,17 +42,17 @@ def update_organization_data_integration_configuration( organization_id:, slug:, enabled: nil, - scopes: nil, + scopes: WorkOS::OMIT, client_id: nil, client_secret: nil, request_options: {} ) body = { "enabled" => enabled, - "scopes" => scopes, "client_id" => client_id, "client_secret" => client_secret }.compact + body["scopes"] = scopes unless scopes.equal?(WorkOS::OMIT) response = @client.request( method: :put, path: "/organizations/#{WorkOS::Util.encode_path(organization_id)}/data_integration_configurations/#{WorkOS::Util.encode_path(slug)}", diff --git a/lib/workos/user_management.rb b/lib/workos/user_management.rb index 43b467c8..b4d90d9c 100644 --- a/lib/workos/user_management.rb +++ b/lib/workos/user_management.rb @@ -820,30 +820,30 @@ def list_users( # @return [WorkOS::UserCreateResponse] def create_user( email:, - first_name: nil, - last_name: nil, - name: nil, - email_verified: nil, - metadata: nil, - external_id: nil, - ip_address: nil, - user_agent: nil, + first_name: WorkOS::OMIT, + last_name: WorkOS::OMIT, + name: WorkOS::OMIT, + email_verified: WorkOS::OMIT, + metadata: WorkOS::OMIT, + external_id: WorkOS::OMIT, + ip_address: WorkOS::OMIT, + user_agent: WorkOS::OMIT, signals_id: nil, password: nil, request_options: {} ) body = { "email" => email, - "first_name" => first_name, - "last_name" => last_name, - "name" => name, - "email_verified" => email_verified, - "metadata" => metadata, - "external_id" => external_id, - "ip_address" => ip_address, - "user_agent" => user_agent, "signals_id" => signals_id }.compact + body["first_name"] = first_name unless first_name.equal?(WorkOS::OMIT) + body["last_name"] = last_name unless last_name.equal?(WorkOS::OMIT) + body["name"] = name unless name.equal?(WorkOS::OMIT) + body["email_verified"] = email_verified unless email_verified.equal?(WorkOS::OMIT) + body["metadata"] = metadata unless metadata.equal?(WorkOS::OMIT) + body["external_id"] = external_id unless external_id.equal?(WorkOS::OMIT) + body["ip_address"] = ip_address unless ip_address.equal?(WorkOS::OMIT) + body["user_agent"] = user_agent unless user_agent.equal?(WorkOS::OMIT) if password case password when WorkOS::UserManagement::PasswordPlaintext @@ -925,9 +925,9 @@ def update_user( last_name: nil, name: nil, email_verified: nil, - metadata: nil, - external_id: nil, - locale: nil, + metadata: WorkOS::OMIT, + external_id: WorkOS::OMIT, + locale: WorkOS::OMIT, password: nil, request_options: {} ) @@ -936,11 +936,11 @@ def update_user( "first_name" => first_name, "last_name" => last_name, "name" => name, - "email_verified" => email_verified, - "metadata" => metadata, - "external_id" => external_id, - "locale" => locale + "email_verified" => email_verified }.compact + body["metadata"] = metadata unless metadata.equal?(WorkOS::OMIT) + body["external_id"] = external_id unless external_id.equal?(WorkOS::OMIT) + body["locale"] = locale unless locale.equal?(WorkOS::OMIT) if password case password when WorkOS::UserManagement::PasswordPlaintext diff --git a/lib/workos/vault.rb b/lib/workos/vault.rb index 7c36e614..d734782c 100644 --- a/lib/workos/vault.rb +++ b/lib/workos/vault.rb @@ -213,13 +213,13 @@ def get_kv( def update_kv( id:, value:, - version_check: nil, + version_check: WorkOS::OMIT, request_options: {} ) body = { - "value" => value, - "version_check" => version_check - }.compact + "value" => value + } + body["version_check"] = version_check unless version_check.equal?(WorkOS::OMIT) response = @client.request( method: :put, path: "/vault/v1/kv/#{WorkOS::Util.encode_path(id)}", diff --git a/test/workos/test_nullable_clearing.rb b/test/workos/test_nullable_clearing.rb new file mode 100644 index 00000000..c8efabd0 --- /dev/null +++ b/test/workos/test_nullable_clearing.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require "test_helper" + +# Verifies the oagen-generated "explicit null clears a nullable field" behavior: +# - omitting a nullable argument leaves the field out of the request body +# - passing an explicit `nil` sends JSON `null` (clearing the field) +# - passing a concrete value sends that value +class NullableClearingTest < Minitest::Test + def setup + @client = WorkOS::Client.new(api_key: "sk_test_123") + end + + def captured_body(verb, url) + body = nil + stub_request(verb, url) + .with { |req| + body = req.body + true + } + .to_return(body: "{}", status: 200) + yield + JSON.parse(body) + end + + def test_omitted_nullable_field_is_not_sent + url = %r{\Ahttps://api\.workos\.com/organizations/org_123(\?|\z)} + body = captured_body(:put, url) do + @client.organizations.update_organization(id: "org_123", name: "New Name") + end + refute body.key?("external_id"), "omitted external_id should not be in the body" + assert_equal "New Name", body["name"] + end + + def test_explicit_nil_clears_nullable_field + url = %r{\Ahttps://api\.workos\.com/organizations/org_123(\?|\z)} + body = captured_body(:put, url) do + @client.organizations.update_organization(id: "org_123", external_id: nil) + end + assert body.key?("external_id"), "explicit nil external_id should be in the body" + assert_nil body["external_id"], "explicit nil external_id should serialize as JSON null" + end + + def test_concrete_value_is_sent + url = %r{\Ahttps://api\.workos\.com/organizations/org_123(\?|\z)} + body = captured_body(:put, url) do + @client.organizations.update_organization(id: "org_123", external_id: "ext-123") + end + assert_equal "ext-123", body["external_id"] + end + + def test_user_explicit_nil_clears_external_id + url = %r{\Ahttps://api\.workos\.com/user_management/users/user_123(\?|\z)} + body = captured_body(:put, url) do + @client.user_management.update_user(id: "user_123", external_id: nil) + end + assert body.key?("external_id"), "explicit nil external_id should be in the body" + assert_nil body["external_id"] + end + + def test_user_omitted_external_id_is_not_sent + url = %r{\Ahttps://api\.workos\.com/user_management/users/user_123(\?|\z)} + body = captured_body(:put, url) do + @client.user_management.update_user(id: "user_123", first_name: "Ada") + end + refute body.key?("external_id") + assert_equal "Ada", body["first_name"] + end +end From ea7c5f20a3edda7d0ca39b15283073d7a07fe93f Mon Sep 17 00:00:00 2001 From: Devin Date: Mon, 20 Jul 2026 21:59:35 +0000 Subject: [PATCH 2/2] test: Assert WorkOS::OMIT sentinel never leaks into body --- test/workos/test_nullable_clearing.rb | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/test/workos/test_nullable_clearing.rb b/test/workos/test_nullable_clearing.rb index c8efabd0..f30ad9d5 100644 --- a/test/workos/test_nullable_clearing.rb +++ b/test/workos/test_nullable_clearing.rb @@ -66,4 +66,17 @@ def test_user_omitted_external_id_is_not_sent refute body.key?("external_id") assert_equal "Ada", body["first_name"] end + + def test_sentinel_never_leaks_into_body + raw = nil + url = %r{\Ahttps://api\.workos\.com/organizations/org_123(\?|\z)} + stub_request(:put, url) + .with { |req| + raw = req.body + true + } + .to_return(body: "{}", status: 200) + @client.organizations.update_organization(id: "org_123", name: "New Name") + refute_match(/OMIT/, raw) + end end