Skip to content
86 changes: 86 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ accurate and fully typed.
- [API Key](#api-key)
- [Personal Access Token](#personal-access-token)
- [Action Attempts](#action-attempts)
- [Setting a value to null](#setting-a-value-to-null)
- [Pagination](#pagination)
- [Manually fetch pages with the next_page_cursor](#manually-fetch-pages-with-the-next_page_cursor)
- [Resume pagination](#resume-pagination)
Expand All @@ -43,6 +44,7 @@ accurate and fully typed.
- [Configuring the Faraday Client](#configuring-the-faraday-client)
- [Using the Faraday Client](#using-the-faraday-client)
- [Overriding the Client](#overriding-the-client)
- [Serializing URL search params](#serializing-url-search-params)
- [Development and Testing](#development-and-testing)
- [Quickstart](#quickstart)
- [Source code](#source-code)
Expand Down Expand Up @@ -226,6 +228,35 @@ rescue Seam::ActionAttemptTimeoutError
end
```

### Setting a value to null

The Seam API distinguishes three states for an updatable parameter:
omitted (leave the stored value unchanged), null (unset the stored value),
and a value (set it).

Ruby's `nil` means omitted.
The SDK removes `nil` parameters from the request entirely,
so passing `nil` never unsets a value.
To unset a value, pass the `Seam::NULL` sentinel,
which the SDK sends as JSON `null` in request bodies
and as an empty value in query strings:

```ruby
require "seam"

seam = Seam.new

# Leaves ends_at unchanged.
seam.access_grants.update(access_grant_id: access_grant_id, ends_at: nil)

# Unsets ends_at so the grant no longer expires.
seam.access_grants.update(access_grant_id: access_grant_id, ends_at: Seam::NULL)
```

Only pass `Seam::NULL` for parameters the API documents as nullable.
Generated methods document nullable parameters
with `Seam::Null` in their `@param` types, e.g. `[String, Seam::Null, nil]`.

### Pagination

Some Seam API endpoints that return lists of resources support pagination.
Expand Down Expand Up @@ -489,6 +520,61 @@ devices = seam.client.get("/devices/list").body["devices"]
A Faraday compatible client may be provided to create a `Seam` instance.
This API is used internally and is not directly supported.

#### Serializing URL search params

The Seam API parses URL search params as complex types.
If you call it with your own HTTP client,
`Seam.serialize_url_search_params` is exported for that purpose.
The `_strict=true` parameter is added to any non-empty query
so the Seam API uses strict, schema-aware parsing.
A query with no serializable params remains empty.

```ruby
require "net/http"
require "seam"

uri = URI("https://connect.getseam.com/devices/list")
uri.query = Seam.serialize_url_search_params({device_ids: ["device1", "device2"]})

Net::HTTP.get(uri, {"Authorization" => "Bearer your-api-key"})
```

The serialization defines the name and value of each search param,
where every value is a string.
`Seam::UrlSearchParams` holds those pairs and renders the query string,
as [URLSearchParams] does for the [reference implementation]:

```ruby
require "seam"

search_params = Seam::UrlSearchParams.new

Seam.update_url_search_params(search_params, {device_ids: ["device1", "device2"]})

search_params.to_a
# => [["device_ids", "device1"], ["device_ids", "device2"], ["_strict", "true"]]

search_params.to_s
# => "device_ids=device1&device_ids=device2&_strict=true"
```

Pass either the query string or the pairs to your HTTP client.
A client may percent-encode a few characters differently
than `URLSearchParams` does,
which the Seam API reads as the same params either way.

A param set to `nil` is omitted,
while a param set to `Seam::NULL` is serialized to an empty value,
which the Seam API reads as null,
as described in [Setting a value to null](#setting-a-value-to-null).
A param that cannot be represented raises a `Seam::UnserializableParamError`.

The Seam API parses these params with the corresponding [parser].

[URLSearchParams]: https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams
[reference implementation]: https://github.com/seamapi/url-search-params-serializer
[parser]: https://github.com/seamapi/url-search-params-parser

## Development and Testing

### Quickstart
Expand Down
10 changes: 6 additions & 4 deletions codegen/lib/handlebars-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,10 +94,12 @@ export const rubyParameterType = (parameter: Parameter): string => {
parameter.format,
parameter.format === 'number' && parameter.isInt,
)
return nullable(type, {
isOptional: !parameter.isRequired,
isNullable: parameter.isNullable,
})
// Only a nullable parameter accepts the Seam::NULL sentinel, and only an
// optional parameter accepts nil.
const union = [type]
if (parameter.isNullable) union.push('Seam::Null')
if (!parameter.isRequired) union.push('nil')
return union.join(', ')
}

export const rubyParamDoc = (
Expand Down
3 changes: 3 additions & 0 deletions lib/seam.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# frozen_string_literal: true

require_relative "seam/null"
require_relative "seam/url_search_params"
require_relative "seam/strict_url_search_params_serializer"
require_relative "seam/http"
require_relative "seam/http_without_workspace"
require_relative "seam/webhook"
Expand Down
23 changes: 23 additions & 0 deletions lib/seam/null.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# frozen_string_literal: true

require "singleton"

module Seam
# The type of the {Seam::NULL} sentinel.
class Null
include Singleton

def to_s
"NULL"
end

def inspect
"NULL"
end
end

# Sentinel for an explicit JSON null: +nil+ omits a parameter, +Seam::NULL+
# unsets its stored value. Only for parameters the API documents as
# nullable.
NULL = Null.instance
end
50 changes: 49 additions & 1 deletion lib/seam/request.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
require_relative "defaults"
require_relative "version"
require_relative "paginator"
require_relative "strict_url_search_params_serializer"

module Seam
module Http
Expand All @@ -16,7 +17,11 @@ def self.create_faraday_client(endpoint, auth_headers, faraday_options = {}, far
default_options = {
url: endpoint,
headers: auth_headers.merge(default_headers),
request: {timeout: timeout, open_timeout: timeout}
request: {
timeout: timeout,
open_timeout: timeout,
params_encoder: UrlSearchParamsEncoder
}
}

options = deep_merge(default_options, faraday_options)
Expand All @@ -33,6 +38,7 @@ def self.create_faraday_client(endpoint, auth_headers, faraday_options = {}, far
faraday_retry_options = default_faraday_retry_options.merge(faraday_retry_options)

Faraday.new(options) do |builder|
builder.use ReplaceNullMiddleware
builder.request :json
builder.use Seam::PaginationMiddleware
builder.response :json
Expand Down Expand Up @@ -97,6 +103,14 @@ def seam_api_error_response?(env)
end
end

class ReplaceNullMiddleware < Faraday::Middleware
def on_request(env)
return unless env.body.is_a?(Hash) || env.body.is_a?(Array)

env.body = Seam.replace_null(env.body)
end
end

def self.deep_merge(hash1, hash2)
result = hash1.dup
hash2.each do |key, value|
Expand All @@ -111,5 +125,39 @@ def self.deep_merge(hash1, hash2)

private_class_method :deep_merge
end

module UrlSearchParamsEncoder
# Pairs decoded from a query string already present in the request
# path, passed through {encode} verbatim rather than re-serialized.
Decoded = Struct.new(:values)

def self.encode(params)
search_params = Seam::UrlSearchParams.new
map_params = {}

params.each do |name, value|
if value.is_a?(Decoded)
value.values.each { |element| search_params.append(name, element) }
else
map_params[name] = value
end
end

return search_params.to_s if map_params.empty?

Seam.update_url_search_params(search_params, map_params)
search_params.to_s
end

# Called by Faraday when a request path carries its own query string.
def self.decode(query)
return {} if query.nil? || query.empty?

pairs = URI.decode_www_form(query.encode(Encoding::UTF_8))
pairs.each_with_object({}) do |(name, value), decoded|
(decoded[name] ||= Decoded.new([])).values << value
end
end
end
end
end
2 changes: 1 addition & 1 deletion lib/seam/routes/access_codes.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion lib/seam/routes/access_codes_unmanaged.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 6 additions & 6 deletions lib/seam/routes/access_grants.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion lib/seam/routes/access_grants_unmanaged.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion lib/seam/routes/access_methods.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion lib/seam/routes/acs_credentials.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion lib/seam/routes/acs_encoders.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading