Skip to content

Commit 2cb5675

Browse files
razor-xclaude
andauthored
feat: implement the URL search params serialization standard (#553)
* feat: Add URL search params serializer Port @seamapi/url-search-params-serializer to Ruby: a UrlSearchParams pair collection with WHATWG form encoding and UTF-16 code unit sort, a serializer with ECMAScript number formatting and JavaScript toISOString date formatting, and the Seam::NULL sentinel distinguishing an explicit JSON null from an omitted parameter. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017p9TwdqYv9ccnv7nGvpfsp * feat: Serialize request params with the URL search params serializer Query params are built by a Faraday params encoder that applies the serializer, so requests carry the exact encoding, ordering, and number formatting the Seam API parses; Faraday's default encoder dropped the '=' from empty-array params, silently turning an empty filter into no filter. Request bodies replace the Seam::NULL sentinel with nil before JSON encoding, and generated methods now document nullable parameters with Seam::Null in their types, consuming the blueprint isNullable flag. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017p9TwdqYv9ccnv7nGvpfsp * test: Add URL search params conformance harness Verify the Ruby serializer byte for byte against the TypeScript reference implementation over hand-built, structural-fuzz, and float-fuzz fixtures, and round-trip the output through @seamapi/url-search-params-parser in strict mode. The harness needs Node and the reference implementation, so it lives in dev/conformance outside the shipped gem. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017p9TwdqYv9ccnv7nGvpfsp * chore: Remove the URL search params conformance harness The serializer standard is stable, so the Node-based comparison against the TypeScript reference and its devDependencies are not worth carrying. The RSpec suite covers the spec directly, including the exact ECMAScript exponent formatting and multi-byte encoding cases the harness verified. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017p9TwdqYv9ccnv7nGvpfsp * feat: Add _strict=true to url_search_params_serializer Match seamapi/python#617: the SDK serializes query params with strict mode enabled, appending _strict=true after the sorted pairs of any non-empty query so the Seam API uses strict, schema-aware parsing. The strict entry points live in strict_url_search_params_serializer so the base serializer stays a faithful port of the standard, and a query built entirely by the caller still passes through without it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017p9TwdqYv9ccnv7nGvpfsp * chore: Trim comments to constraints the code cannot show Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017p9TwdqYv9ccnv7nGvpfsp * chore: Delete comments that restate tested behavior Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017p9TwdqYv9ccnv7nGvpfsp * docs: Match the URL search params section to the other SDKs Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017p9TwdqYv9ccnv7nGvpfsp * Move UrlSearchParamsEncoder down --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 48a0d2b commit 2cb5675

35 files changed

Lines changed: 1204 additions & 57 deletions

‎README.md‎

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ accurate and fully typed.
2727
- [API Key](#api-key)
2828
- [Personal Access Token](#personal-access-token)
2929
- [Action Attempts](#action-attempts)
30+
- [Setting a value to null](#setting-a-value-to-null)
3031
- [Pagination](#pagination)
3132
- [Manually fetch pages with the next_page_cursor](#manually-fetch-pages-with-the-next_page_cursor)
3233
- [Resume pagination](#resume-pagination)
@@ -43,6 +44,7 @@ accurate and fully typed.
4344
- [Configuring the Faraday Client](#configuring-the-faraday-client)
4445
- [Using the Faraday Client](#using-the-faraday-client)
4546
- [Overriding the Client](#overriding-the-client)
47+
- [Serializing URL search params](#serializing-url-search-params)
4648
- [Development and Testing](#development-and-testing)
4749
- [Quickstart](#quickstart)
4850
- [Source code](#source-code)
@@ -226,6 +228,35 @@ rescue Seam::ActionAttemptTimeoutError
226228
end
227229
```
228230

231+
### Setting a value to null
232+
233+
The Seam API distinguishes three states for an updatable parameter:
234+
omitted (leave the stored value unchanged), null (unset the stored value),
235+
and a value (set it).
236+
237+
Ruby's `nil` means omitted.
238+
The SDK removes `nil` parameters from the request entirely,
239+
so passing `nil` never unsets a value.
240+
To unset a value, pass the `Seam::NULL` sentinel,
241+
which the SDK sends as JSON `null` in request bodies
242+
and as an empty value in query strings:
243+
244+
```ruby
245+
require "seam"
246+
247+
seam = Seam.new
248+
249+
# Leaves ends_at unchanged.
250+
seam.access_grants.update(access_grant_id: access_grant_id, ends_at: nil)
251+
252+
# Unsets ends_at so the grant no longer expires.
253+
seam.access_grants.update(access_grant_id: access_grant_id, ends_at: Seam::NULL)
254+
```
255+
256+
Only pass `Seam::NULL` for parameters the API documents as nullable.
257+
Generated methods document nullable parameters
258+
with `Seam::Null` in their `@param` types, e.g. `[String, Seam::Null, nil]`.
259+
229260
### Pagination
230261

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

523+
#### Serializing URL search params
524+
525+
The Seam API parses URL search params as complex types.
526+
If you call it with your own HTTP client,
527+
`Seam.serialize_url_search_params` is exported for that purpose.
528+
The `_strict=true` parameter is added to any non-empty query
529+
so the Seam API uses strict, schema-aware parsing.
530+
A query with no serializable params remains empty.
531+
532+
```ruby
533+
require "net/http"
534+
require "seam"
535+
536+
uri = URI("https://connect.getseam.com/devices/list")
537+
uri.query = Seam.serialize_url_search_params({device_ids: ["device1", "device2"]})
538+
539+
Net::HTTP.get(uri, {"Authorization" => "Bearer your-api-key"})
540+
```
541+
542+
The serialization defines the name and value of each search param,
543+
where every value is a string.
544+
`Seam::UrlSearchParams` holds those pairs and renders the query string,
545+
as [URLSearchParams] does for the [reference implementation]:
546+
547+
```ruby
548+
require "seam"
549+
550+
search_params = Seam::UrlSearchParams.new
551+
552+
Seam.update_url_search_params(search_params, {device_ids: ["device1", "device2"]})
553+
554+
search_params.to_a
555+
# => [["device_ids", "device1"], ["device_ids", "device2"], ["_strict", "true"]]
556+
557+
search_params.to_s
558+
# => "device_ids=device1&device_ids=device2&_strict=true"
559+
```
560+
561+
Pass either the query string or the pairs to your HTTP client.
562+
A client may percent-encode a few characters differently
563+
than `URLSearchParams` does,
564+
which the Seam API reads as the same params either way.
565+
566+
A param set to `nil` is omitted,
567+
while a param set to `Seam::NULL` is serialized to an empty value,
568+
which the Seam API reads as null,
569+
as described in [Setting a value to null](#setting-a-value-to-null).
570+
A param that cannot be represented raises a `Seam::UnserializableParamError`.
571+
572+
The Seam API parses these params with the corresponding [parser].
573+
574+
[URLSearchParams]: https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams
575+
[reference implementation]: https://github.com/seamapi/url-search-params-serializer
576+
[parser]: https://github.com/seamapi/url-search-params-parser
577+
492578
## Development and Testing
493579

494580
### Quickstart

‎codegen/lib/handlebars-helpers.ts‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -94,10 +94,12 @@ export const rubyParameterType = (parameter: Parameter): string => {
9494
parameter.format,
9595
parameter.format === 'number' && parameter.isInt,
9696
)
97-
return nullable(type, {
98-
isOptional: !parameter.isRequired,
99-
isNullable: parameter.isNullable,
100-
})
97+
// Only a nullable parameter accepts the Seam::NULL sentinel, and only an
98+
// optional parameter accepts nil.
99+
const union = [type]
100+
if (parameter.isNullable) union.push('Seam::Null')
101+
if (!parameter.isRequired) union.push('nil')
102+
return union.join(', ')
101103
}
102104

103105
export const rubyParamDoc = (

‎lib/seam.rb‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# frozen_string_literal: true
22

3+
require_relative "seam/null"
4+
require_relative "seam/url_search_params"
5+
require_relative "seam/strict_url_search_params_serializer"
36
require_relative "seam/http"
47
require_relative "seam/http_without_workspace"
58
require_relative "seam/webhook"

‎lib/seam/null.rb‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# frozen_string_literal: true
2+
3+
require "singleton"
4+
5+
module Seam
6+
# The type of the {Seam::NULL} sentinel.
7+
class Null
8+
include Singleton
9+
10+
def to_s
11+
"NULL"
12+
end
13+
14+
def inspect
15+
"NULL"
16+
end
17+
end
18+
19+
# Sentinel for an explicit JSON null: +nil+ omits a parameter, +Seam::NULL+
20+
# unsets its stored value. Only for parameters the API documents as
21+
# nullable.
22+
NULL = Null.instance
23+
end

‎lib/seam/request.rb‎

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
require_relative "defaults"
66
require_relative "version"
77
require_relative "paginator"
8+
require_relative "strict_url_search_params_serializer"
89

910
module Seam
1011
module Http
@@ -16,7 +17,11 @@ def self.create_faraday_client(endpoint, auth_headers, faraday_options = {}, far
1617
default_options = {
1718
url: endpoint,
1819
headers: auth_headers.merge(default_headers),
19-
request: {timeout: timeout, open_timeout: timeout}
20+
request: {
21+
timeout: timeout,
22+
open_timeout: timeout,
23+
params_encoder: UrlSearchParamsEncoder
24+
}
2025
}
2126

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

3540
Faraday.new(options) do |builder|
41+
builder.use ReplaceNullMiddleware
3642
builder.request :json
3743
builder.use Seam::PaginationMiddleware
3844
builder.response :json
@@ -97,6 +103,14 @@ def seam_api_error_response?(env)
97103
end
98104
end
99105

106+
class ReplaceNullMiddleware < Faraday::Middleware
107+
def on_request(env)
108+
return unless env.body.is_a?(Hash) || env.body.is_a?(Array)
109+
110+
env.body = Seam.replace_null(env.body)
111+
end
112+
end
113+
100114
def self.deep_merge(hash1, hash2)
101115
result = hash1.dup
102116
hash2.each do |key, value|
@@ -111,5 +125,39 @@ def self.deep_merge(hash1, hash2)
111125

112126
private_class_method :deep_merge
113127
end
128+
129+
module UrlSearchParamsEncoder
130+
# Pairs decoded from a query string already present in the request
131+
# path, passed through {encode} verbatim rather than re-serialized.
132+
Decoded = Struct.new(:values)
133+
134+
def self.encode(params)
135+
search_params = Seam::UrlSearchParams.new
136+
map_params = {}
137+
138+
params.each do |name, value|
139+
if value.is_a?(Decoded)
140+
value.values.each { |element| search_params.append(name, element) }
141+
else
142+
map_params[name] = value
143+
end
144+
end
145+
146+
return search_params.to_s if map_params.empty?
147+
148+
Seam.update_url_search_params(search_params, map_params)
149+
search_params.to_s
150+
end
151+
152+
# Called by Faraday when a request path carries its own query string.
153+
def self.decode(query)
154+
return {} if query.nil? || query.empty?
155+
156+
pairs = URI.decode_www_form(query.encode(Encoding::UTF_8))
157+
pairs.each_with_object({}) do |(name, value), decoded|
158+
(decoded[name] ||= Decoded.new([])).values << value
159+
end
160+
end
161+
end
114162
end
115163
end

‎lib/seam/routes/access_codes.rb‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎lib/seam/routes/access_codes_unmanaged.rb‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎lib/seam/routes/access_grants.rb‎

Lines changed: 6 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎lib/seam/routes/access_grants_unmanaged.rb‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎lib/seam/routes/access_methods.rb‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)