Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 29 additions & 4 deletions lib/workos/base_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
require "securerandom"
require "uri"
require "workos/errors"
require "workos/null"

module WorkOS
# Instance-scoped HTTP runtime that implements request execution,
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Comment thread
greptile-apps[bot] marked this conversation as resolved.

# Redact path segments that carry bearer-equivalent tokens (e.g.
# `/user_management/invitations/by_token/<token>`,
# `/user_management/magic_auth/<token>`, password-reset / email-
Expand Down
40 changes: 40 additions & 0 deletions lib/workos/null.rb
Original file line number Diff line number Diff line change
@@ -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
71 changes: 71 additions & 0 deletions test/workos/test_null.rb
Original file line number Diff line number Diff line change
@@ -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
Loading