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..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, @@ -66,7 +67,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 +76,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 +85,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 +95,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 +181,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