From c193d308d0100afbf624f1eaf152c506faeec7a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 19:06:12 +0000 Subject: [PATCH 1/9] 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 Claude-Session: https://claude.ai/code/session_017p9TwdqYv9ccnv7nGvpfsp --- README.md | 34 ++++ lib/seam.rb | 3 + lib/seam/null.rb | 35 ++++ lib/seam/url_search_params.rb | 130 ++++++++++++ lib/seam/url_search_params_serializer.rb | 231 ++++++++++++++++++++++ spec/null_spec.rb | 25 +++ spec/url_search_params_serializer_spec.rb | 220 +++++++++++++++++++++ spec/url_search_params_spec.rb | 128 ++++++++++++ 8 files changed, 806 insertions(+) create mode 100644 lib/seam/null.rb create mode 100644 lib/seam/url_search_params.rb create mode 100644 lib/seam/url_search_params_serializer.rb create mode 100644 spec/null_spec.rb create mode 100644 spec/url_search_params_serializer_spec.rb create mode 100644 spec/url_search_params_spec.rb diff --git a/README.md b/README.md index e79cf7c..6092bc5 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,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) + - [URL search params serialization](#url-search-params-serialization) - [Development and Testing](#development-and-testing) - [Quickstart](#quickstart) - [Source code](#source-code) @@ -489,6 +490,39 @@ 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. +#### URL search params serialization + +The SDK serializes query params with a Ruby port of +[@seamapi/url-search-params-serializer][serializer], +which the Seam API parses with +[@seamapi/url-search-params-parser][parser]. +The output is byte-for-byte identical to the TypeScript implementation: +WHATWG `application/x-www-form-urlencoded` encoding, +`URLSearchParams.sort()` ordering, and ECMAScript number formatting. + +The serializer is exported for callers making requests +with their own HTTP client: + +```ruby +require "seam" + +Seam.serialize_url_search_params( + device_ids: ["device-1", "device-2"], + custom_metadata_has: {internal_account_id: "user-1"}, + limit: 10 +) +# => "custom_metadata_has.internal_account_id=user-1&device_ids=device-1&device_ids=device-2&limit=10" +``` + +Use `Seam.update_url_search_params` to merge params into an existing +`Seam::UrlSearchParams` collection, e.g. for a URL that already has a query. +Parameters that cannot be represented in the standard raise a +`Seam::UnserializableParamError` before any request is sent, +with the offending parameter name available as `param_name`. + +[serializer]: https://github.com/seamapi/url-search-params-serializer +[parser]: https://github.com/seamapi/url-search-params-parser + ## Development and Testing ### Quickstart diff --git a/lib/seam.rb b/lib/seam.rb index 66205c3..f301045 100644 --- a/lib/seam.rb +++ b/lib/seam.rb @@ -1,5 +1,8 @@ # frozen_string_literal: true +require_relative "seam/null" +require_relative "seam/url_search_params" +require_relative "seam/url_search_params_serializer" require_relative "seam/http" require_relative "seam/http_without_workspace" require_relative "seam/webhook" diff --git a/lib/seam/null.rb b/lib/seam/null.rb new file mode 100644 index 0000000..6281239 --- /dev/null +++ b/lib/seam/null.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require "singleton" + +module Seam + # The type of the {Seam::NULL} sentinel. + # + # Exported alongside the sentinel so callers can reference the type itself, + # e.g. in YARD documentation or case expressions. Detect the sentinel with + # +value.is_a?(Seam::Null)+ rather than an identity check, which would break + # if the library were somehow loaded twice. + class Null + include Singleton + + def to_s + "NULL" + end + + def inspect + "NULL" + end + end + + # Sentinel for an explicit JSON null. + # + # The Seam API distinguishes three states for a parameter: absent (leave the + # stored value unchanged), null (unset the stored value), and a value (set + # it). Ruby's +nil+ has to mean one of the first two, and it means absent: + # unsetting a value cannot be undone and is rarely intended, so it is never + # the default and is always spelled explicitly as +Seam::NULL+. + # + # Only use +Seam::NULL+ for parameters the API documents as nullable. In a + # query string it serializes as +name=+ and in a JSON body as +"name": null+. + NULL = Null.instance +end diff --git a/lib/seam/url_search_params.rb b/lib/seam/url_search_params.rb new file mode 100644 index 0000000..2caaa30 --- /dev/null +++ b/lib/seam/url_search_params.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +require "uri" + +module Seam + # A mutable, ordered list of name/value string pairs modeling the parts of + # the WHATWG URLSearchParams interface that the Seam URL search params + # serializer needs. A name may repeat, which is how arrays are represented. + # + # Pairs are encoded with the WHATWG application/x-www-form-urlencoded + # serializer and sorted by UTF-16 code unit, matching JavaScript's + # URLSearchParams exactly. + class UrlSearchParams + include Enumerable + + # @param init [String, Hash, Enumerable, nil] An optional query string + # (with or without a leading +?+), hash, or sequence of name/value pairs. + def initialize(init = nil) + @pairs = [] + + case init + when nil + # Start empty. + when String + query = init.delete_prefix("?") + URI.decode_www_form(query).each { |name, value| append(name, value) } unless query.empty? + when Hash + init.each { |name, value| append(name, value) } + else + init.each { |name, value| append(name, value) } + end + end + + # Adds a pair, keeping any existing pairs with the same name. + def append(name, value) + @pairs << [name.to_s, value.to_s] + nil + end + + # Replaces the value of the first pair with the given name in place and + # deletes the rest, or appends the pair if the name is absent. + def set(name, value) + name = name.to_s + replaced = false + @pairs = @pairs.filter_map do |pair| + next pair unless pair.first == name + next nil if replaced + + replaced = true + [name, value.to_s] + end + append(name, value) unless replaced + nil + end + + # @return [String, nil] The value of the first pair with the given name. + def get(name) + name = name.to_s + @pairs.each { |pair_name, value| return value if pair_name == name } + nil + end + + # @return [Array] The values of all pairs with the given name. + def get_all(name) + name = name.to_s + @pairs.filter_map { |pair_name, value| value if pair_name == name } + end + + def has?(name) + name = name.to_s + @pairs.any? { |pair_name, _| pair_name == name } + end + + # Removes all pairs with the given name. + def delete(name) + name = name.to_s + @pairs.reject! { |pair_name, _| pair_name == name } + nil + end + + # Sorts pairs by name in UTF-16 code unit order, like + # URLSearchParams#sort. The sort is stable, so pairs with the same name + # keep their relative order, which preserves array element order. + # + # UTF-16 code unit order differs from both code point order and UTF-8 byte + # order above the Basic Multilingual Plane: surrogate pairs sort below + # U+E000..U+FFFF. Comparing the UTF-16BE encoding of each name as bytes + # produces exactly this order. + def sort! + @pairs = @pairs.each_with_index.sort_by do |(name, _), index| + [name.encode(Encoding::UTF_16BE).b, index] + end.map(&:first) + nil + end + + def each(&block) + return @pairs.each unless block + + @pairs.each(&block) + self + end + + def size + @pairs.size + end + alias_method :length, :size + + def empty? + @pairs.empty? + end + + # @return [String] The pairs as an application/x-www-form-urlencoded + # query string with no leading +?+. Every pair gets an +=+, including + # pairs with an empty value. + def to_s + @pairs.map do |name, value| + "#{self.class.encode_component(name)}=#{self.class.encode_component(value)}" + end.join("&") + end + + # Encodes a string with the WHATWG application/x-www-form-urlencoded + # serializer: ASCII alphanumerics and +*-._+ are emitted literally, space + # becomes ++, and every other UTF-8 byte becomes an uppercase %XX escape. + # Ruby's stdlib implements exactly this, verified by the probe + # +encode_component("a *~ b") == "a+*%7E+b"+ in the specs. + def self.encode_component(string) + URI.encode_www_form_component(string.encode(Encoding::UTF_8)) + end + end +end diff --git a/lib/seam/url_search_params_serializer.rb b/lib/seam/url_search_params_serializer.rb new file mode 100644 index 0000000..2e276f6 --- /dev/null +++ b/lib/seam/url_search_params_serializer.rb @@ -0,0 +1,231 @@ +# frozen_string_literal: true + +require "date" + +require_relative "null" +require_relative "url_search_params" + +module Seam + # Raised when a parameter cannot be serialized to a URL query string. This + # is raised before any request is sent, so callers can distinguish a bad + # parameter from an API rejection. + class UnserializableParamError < StandardError + # @return [String] The name of the parameter that could not be serialized. + attr_reader :param_name + + def initialize(param_name, reason) + @param_name = param_name + super("Could not serialize parameter: '#{param_name}' #{reason}") + end + end + + # Serializes parameters to a URL query string following the + # @seamapi/url-search-params-serializer standard: + # https://github.com/seamapi/url-search-params-serializer + # + # The output is byte-for-byte identical to the TypeScript reference + # implementation: WHATWG application/x-www-form-urlencoded encoding, + # URLSearchParams#sort ordering, and ECMAScript number formatting. + # + # @param params [Hash] Parameter names mapped to values. Nested hashes join + # their keys with +.+, arrays repeat the name, +nil+ values are omitted, + # and {Seam::NULL} serializes as an empty value. + # @return [String] The query string with no leading +?+. + # @raise [UnserializableParamError] + def self.serialize_url_search_params(params) + search_params = UrlSearchParams.new + update_url_search_params(search_params, params) + search_params.to_s + end + + # Serializes parameters into an existing {UrlSearchParams} collection, + # preserving pairs it does not overwrite, then sorts the collection. Use + # this to merge Seam parameters into a URL that already has a query. + # + # @param search_params [UrlSearchParams] + # @param params [Hash] + # @return [nil] + # @raise [UnserializableParamError] + def self.update_url_search_params(search_params, params) + UrlSearchParamsSerializer.update(search_params, params, []) + search_params.sort! + nil + end + + # Returns a copy of the value with every {Seam::NULL} sentinel replaced by + # +nil+, recursing into hashes and arrays, so the sentinel serializes to + # JSON null in request bodies. Never mutates the given value. + def self.replace_null(value) + case value + when Seam::Null then nil + when Hash then value.transform_values { |v| replace_null(v) } + when Array then value.map { |v| replace_null(v) } + else value + end + end + + # Internal implementation of {Seam.serialize_url_search_params}. + module UrlSearchParamsSerializer + def self.update(search_params, params, path) + params.each do |key, value| + unless key.is_a?(String) || key.is_a?(Symbol) + raise UnserializableParamError.new( + key.inspect, + "has a name that is not a string which is unsupported" + ) + end + key = key.to_s + + if key.include?(".") + raise UnserializableParamError.new( + key, + 'contains one or more dots "." in its name which is unsupported' + ) + end + + current_path = [*path, key] + + if value.is_a?(Hash) + update(search_params, value, current_path) + next + end + + name = current_path.join(".") + + next if value.nil? + + value = value.to_s if value.is_a?(Symbol) + + next if value.is_a?(String) && value.empty? + + if value.is_a?(Array) + serialize_array(search_params, name, value) + next + end + + search_params.set(name, serialize_value(name, value)) + end + end + + def self.serialize_array(search_params, name, values) + # An empty array serializes as a single pair with an empty value, which + # the parser reads back as an empty array. Omitting the pair instead + # would silently drop the filter and match everything. + if values.empty? + search_params.set(name, "") + return + end + + values = values.map { |value| value.is_a?(Symbol) ? value.to_s : value } + + if values.length == 1 && values.first == "" + raise UnserializableParamError.new( + name, + "is a single element array containing the empty string which is unsupported" + ) + end + + if values.any? { |value| value == "" } + raise UnserializableParamError.new( + name, + "is an array containing the empty string which is unsupported" + ) + end + + if values.any? { |value| value.nil? || value.is_a?(Seam::Null) } + raise UnserializableParamError.new( + name, + "is an array containing null or undefined values which is unsupported" + ) + end + + values.each { |value| search_params.append(name, serialize_value(name, value)) } + end + + def self.serialize_value(name, value) + case value + when Seam::Null then "" + when String then value + when true, false then value.to_s + when Integer then value.to_s + when Float then serialize_float(name, value) + when Time then serialize_time(value) + when DateTime then serialize_time(value.to_time) + when Date then serialize_time(Time.utc(value.year, value.month, value.day)) + else + raise UnserializableParamError.new(name, "is a #{value.class}") + end + end + + # Formats a float exactly like ECMAScript Number::toString: the shortest + # digit string that round-trips, a decimal point placed directly for + # exponents in (-7, 21), and e-notation outside that range. + def self.serialize_float(name, value) + raise UnserializableParamError.new(name, "is NaN") if value.nan? + if value.infinite? + raise UnserializableParamError.new(name, "is #{value.positive? ? "Infinity" : "-Infinity"}") + end + return "0" if value.zero? + + digits, point = shortest_decimal(value.abs) + count = digits.length + + formatted = if point.between?(count, 21) + digits + "0" * (point - count) + elsif point.positive? && point <= 21 + "#{digits[0, point]}.#{digits[point..]}" + elsif point > -6 && point <= 0 + "0.#{"0" * -point}#{digits}" + else + mantissa = (count == 1) ? digits : "#{digits[0]}.#{digits[1..]}" + exponent = point - 1 + "#{mantissa}e#{(exponent >= 0) ? "+" : "-"}#{exponent.abs}" + end + + value.negative? ? "-#{formatted}" : formatted + end + + # Returns the shortest round-tripping decimal digits of a positive float + # (trailing zeros stripped) and the position of the decimal point relative + # to the first digit. Ruby's Float#to_s is the shortest repr that + # round-trips, the same digits ECMAScript uses. + def self.shortest_decimal(value) + repr = value.to_s + + if repr.include?("e") + mantissa, exponent = repr.split("e") + integer_part, fraction_part = mantissa.split(".") + digits = integer_part + (fraction_part || "") + point = integer_part.length + exponent.to_i + else + integer_part, fraction_part = repr.split(".") + digits = integer_part + (fraction_part || "") + point = integer_part.length + end + + leading_zeros = digits[/\A0*/].length + digits = digits[leading_zeros..] + point -= leading_zeros + + [digits.sub(/0+\z/, ""), point] + end + + # Formats a time exactly like JavaScript's Date#toISOString: converted to + # UTC, millisecond precision with sub-millisecond digits truncated, and a + # literal Z. Years outside 0000..9999 use the expanded six-digit form. + def self.serialize_time(time) + utc = time.getutc + year = if utc.year.between?(0, 9999) + format("%04d", utc.year) + elsif utc.year > 9999 + format("+%06d", utc.year) + else + format("-%06d", -utc.year) + end + format( + "%s-%02d-%02dT%02d:%02d:%02d.%03dZ", + year, utc.month, utc.day, utc.hour, utc.min, utc.sec, utc.nsec / 1_000_000 + ) + end + end +end diff --git a/spec/null_spec.rb b/spec/null_spec.rb new file mode 100644 index 0000000..2211ecc --- /dev/null +++ b/spec/null_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +RSpec.describe Seam::NULL do + it "is the single instance of Seam::Null" do + expect(described_class).to be_a(Seam::Null) + expect(described_class).to equal(Seam::Null.instance) + end + + it "cannot be constructed a second time" do + expect { Seam::Null.new }.to raise_error(NoMethodError) + end + + it "is detected by type rather than identity" do + expect(described_class.is_a?(Seam::Null)).to be true + end + + it "reads as its own name in error messages and debuggers" do + expect(described_class.to_s).to eq("NULL") + expect(described_class.inspect).to eq("NULL") + end + + it "is not nil, so Hash#compact keeps it" do + expect({name: described_class}.compact).to eq({name: described_class}) + end +end diff --git a/spec/url_search_params_serializer_spec.rb b/spec/url_search_params_serializer_spec.rb new file mode 100644 index 0000000..aee74bd --- /dev/null +++ b/spec/url_search_params_serializer_spec.rb @@ -0,0 +1,220 @@ +# frozen_string_literal: true + +RSpec.describe "Seam.serialize_url_search_params" do + def serialize(params) + Seam.serialize_url_search_params(params) + end + + it "serializes scalars and sorts pairs by name" do + expect(serialize({foo: "d", bar: 2})).to eq("bar=2&foo=d") + end + + it "accepts string and symbol keys" do + expect(serialize({"a" => 1, :b => 2})).to eq("a=1&b=2") + end + + it "serializes symbol values as strings" do + expect(serialize({mode: :heating})).to eq("mode=heating") + end + + describe "absent, null, and empty values" do + it "omits nil params entirely" do + expect(serialize({a: nil, b: "x"})).to eq("b=x") + end + + it "serializes the NULL sentinel as an empty value" do + expect(serialize({a: Seam::NULL, b: "x"})).to eq("a=&b=x") + end + + it "omits empty string params entirely" do + expect(serialize({a: "", b: "x"})).to eq("b=x") + end + + it "serializes to an empty string when nothing is serializable" do + expect(serialize({})).to eq("") + expect(serialize({a: nil, b: ""})).to eq("") + end + end + + describe "nested hashes" do + it "joins nested keys with dots" do + expect(serialize({a: {b: {c: 1}}})).to eq("a.b.c=1") + end + + it "rejects keys containing dots" do + expect { serialize({"a.b" => 1}) }.to raise_error(Seam::UnserializableParamError) do |error| + expect(error.param_name).to eq("a.b") + expect(error.message).to eq( + "Could not serialize parameter: 'a.b' contains one or more dots \".\" in its name which is unsupported" + ) + end + end + + it "rejects nested keys containing dots" do + expect { serialize({a: {"b.c" => 1}}) }.to raise_error(Seam::UnserializableParamError) + end + + it "rejects keys that are not strings or symbols" do + expect { serialize({1 => "x"}) }.to raise_error(Seam::UnserializableParamError) + end + end + + describe "arrays" do + it "repeats the name for each element, preserving order" do + expect(serialize({ids: %w[b a]})).to eq("ids=b&ids=a") + end + + it "serializes an empty array as a single pair with an empty value" do + expect(serialize({ids: []})).to eq("ids=") + end + + it "rejects a single element array containing the empty string" do + expect { serialize({ids: [""]}) }.to raise_error( + Seam::UnserializableParamError, + "Could not serialize parameter: 'ids' is a single element array containing the empty string which is unsupported" + ) + end + + it "rejects arrays containing the empty string" do + expect { serialize({ids: ["a", ""]}) }.to raise_error( + Seam::UnserializableParamError, + "Could not serialize parameter: 'ids' is an array containing the empty string which is unsupported" + ) + end + + it "rejects arrays containing nil or NULL" do + message = "Could not serialize parameter: 'ids' is an array containing null or undefined values which is unsupported" + expect { serialize({ids: ["a", nil]}) }.to raise_error(Seam::UnserializableParamError, message) + expect { serialize({ids: ["a", Seam::NULL]}) }.to raise_error(Seam::UnserializableParamError, message) + end + end + + it "serializes booleans as true and false" do + expect(serialize({a: true, b: false})).to eq("a=true&b=false") + end + + describe "numbers" do + it "serializes integers with full decimal digits at arbitrary precision" do + expect(serialize({n: 123456789123456789123456789})).to eq("n=123456789123456789123456789") + expect(serialize({n: -42})).to eq("n=-42") + end + + it "serializes integral floats without a trailing .0" do + expect(serialize({n: 1.0})).to eq("n=1") + expect(serialize({n: -100.0})).to eq("n=-100") + end + + it "serializes zero as 0, including negative zero" do + expect(serialize({n: 0.0})).to eq("n=0") + expect(serialize({n: -0.0})).to eq("n=0") + end + + it "switches to exponent notation at 1e21 but not 1e20" do + expect(serialize({n: 1e20})).to eq("n=100000000000000000000") + expect(serialize({n: 1e21})).to eq("n=1e%2B21") + end + + it "switches to exponent notation at 1e-7 but not 1e-6" do + expect(serialize({n: 1e-6})).to eq("n=0.000001") + expect(serialize({n: 1e-7})).to eq("n=1e-7") + end + + it "serializes the shortest round-tripping digits" do + expect(serialize({n: 0.1 + 0.2})).to eq("n=0.30000000000000004") + expect(serialize({n: 123.456})).to eq("n=123.456") + end + + it "rejects NaN and infinities with their own messages" do + expect { serialize({n: Float::NAN}) }.to raise_error( + Seam::UnserializableParamError, "Could not serialize parameter: 'n' is NaN" + ) + expect { serialize({n: Float::INFINITY}) }.to raise_error( + Seam::UnserializableParamError, "Could not serialize parameter: 'n' is Infinity" + ) + expect { serialize({n: -Float::INFINITY}) }.to raise_error( + Seam::UnserializableParamError, "Could not serialize parameter: 'n' is -Infinity" + ) + end + end + + describe "times" do + it "serializes with exactly three fractional digits and a literal Z" do + expect(serialize({t: Time.utc(2024, 1, 2, 3, 4, 5)})).to eq("t=2024-01-02T03%3A04%3A05.000Z") + end + + it "converts to UTC first" do + time = Time.new(2024, 1, 2, 3, 4, 5, "+05:00") + expect(serialize({t: time})).to eq("t=2024-01-01T22%3A04%3A05.000Z") + end + + it "truncates sub-millisecond precision toward zero" do + time = Time.utc(2024, 1, 2, 3, 4, 5, 123_999.999) + expect(serialize({t: time})).to eq("t=2024-01-02T03%3A04%3A05.123Z") + end + + it "zero-pads the year to four digits" do + expect(serialize({t: Time.utc(999, 1, 2)})).to eq("t=0999-01-02T00%3A00%3A00.000Z") + end + + it "uses the expanded six-digit form for years outside 0000..9999, like Date#toISOString" do + expect(serialize({t: Time.utc(10_000, 1, 1)})).to eq("t=%2B010000-01-01T00%3A00%3A00.000Z") + expect(serialize({t: Time.utc(-5, 1, 1)})).to eq("t=-000005-01-01T00%3A00%3A00.000Z") + end + + it "serializes DateTime and Date values" do + expect(serialize({t: DateTime.new(2024, 1, 2, 3, 4, 5, "+00:00")})) + .to eq("t=2024-01-02T03%3A04%3A05.000Z") + expect(serialize({t: Date.new(2024, 1, 2)})).to eq("t=2024-01-02T00%3A00%3A00.000Z") + end + end + + describe "unsupported values" do + it "rejects unordered collections, which would not serialize deterministically" do + expect { serialize({s: Set.new(["a"])}) }.to raise_error( + Seam::UnserializableParamError, "Could not serialize parameter: 's' is a Set" + ) + end + + it "rejects other objects with the param name retrievable from the error" do + expect { serialize({r: Rational(1, 2)}) }.to raise_error(Seam::UnserializableParamError) do |error| + expect(error.param_name).to eq("r") + end + end + + it "raises before anything is serialized rather than sending a partial query" do + expect { serialize({a: "ok", b: Object.new}) }.to raise_error(Seam::UnserializableParamError) + end + end + + describe "Seam.update_url_search_params" do + it "serializes into an existing collection, preserving other pairs, then sorts" do + search_params = Seam::UrlSearchParams.new("z=1&a=2") + Seam.update_url_search_params(search_params, {m: "x"}) + + expect(search_params.to_s).to eq("a=2&m=x&z=1") + end + + it "overwrites pairs with names it serializes" do + search_params = Seam::UrlSearchParams.new("a=old") + Seam.update_url_search_params(search_params, {a: "new"}) + + expect(search_params.to_s).to eq("a=new") + end + end + + describe "Seam.replace_null" do + it "replaces the sentinel with nil recursively, returning a copy" do + payload = {a: Seam::NULL, b: [Seam::NULL, 1], c: {d: Seam::NULL}, e: "kept"} + replaced = Seam.replace_null(payload) + + expect(replaced).to eq({a: nil, b: [nil, 1], c: {d: nil}, e: "kept"}) + expect(payload[:a]).to equal(Seam::NULL) + expect(payload[:b].first).to equal(Seam::NULL) + expect(payload[:c][:d]).to equal(Seam::NULL) + end + + it "does not descend into strings" do + expect(Seam.replace_null({a: "NULL"})).to eq({a: "NULL"}) + end + end +end diff --git a/spec/url_search_params_spec.rb b/spec/url_search_params_spec.rb new file mode 100644 index 0000000..b19b0d0 --- /dev/null +++ b/spec/url_search_params_spec.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +RSpec.describe Seam::UrlSearchParams do + describe "#append" do + it "adds a pair, keeping existing pairs with that name" do + params = described_class.new + params.append("a", "1") + params.append("a", "2") + + expect(params.get_all("a")).to eq(%w[1 2]) + expect(params.to_s).to eq("a=1&a=2") + end + end + + describe "#set" do + it "replaces the first pair in place and deletes the rest" do + params = described_class.new([%w[a 1], %w[b 2], %w[a 3]]) + params.set("a", "9") + + expect(params.to_s).to eq("a=9&b=2") + end + + it "appends when the name is absent" do + params = described_class.new([%w[a 1]]) + params.set("b", "2") + + expect(params.to_s).to eq("a=1&b=2") + end + end + + describe "#get" do + it "returns the value of the first pair with that name" do + params = described_class.new([%w[a 1], %w[a 2]]) + + expect(params.get("a")).to eq("1") + expect(params.get("missing")).to be_nil + end + end + + describe "#has?" do + it "reports whether a pair with that name exists" do + params = described_class.new([%w[a 1]]) + + expect(params.has?("a")).to be true + expect(params.has?("b")).to be false + end + end + + describe "#delete" do + it "removes all pairs with that name" do + params = described_class.new([%w[a 1], %w[b 2], %w[a 3]]) + params.delete("a") + + expect(params.to_s).to eq("b=2") + end + end + + describe "#sort!" do + it "sorts by UTF-16 code unit, putting astral characters before U+E000..U+FFFF" do + params = described_class.new + params.append("\u{FFFF}", "1") + params.append("\u{1F600}", "2") + params.sort! + + expect(params.map(&:first)).to eq(["\u{1F600}", "\u{FFFF}"]) + end + + it "is stable, preserving the order of pairs with the same name" do + params = described_class.new([%w[b 2], %w[a x], %w[a y], %w[b 1]]) + params.sort! + + expect(params.to_s).to eq("a=x&a=y&b=2&b=1") + end + end + + describe "#to_s" do + it "gives every pair an =, including empty values" do + params = described_class.new([["a", ""], %w[b 2]]) + + expect(params.to_s).to eq("a=&b=2") + end + + it "encodes with the WHATWG form serializer: space to +, ~ escaped, * literal" do + params = described_class.new + params.append("k", "a *~ b") + + expect(params.to_s).to eq("k=a+*%7E+b") + end + + it "escapes multi-byte UTF-8 one uppercase escape per byte" do + params = described_class.new + params.append("k", "\u{1F600}") + + expect(params.to_s).to eq("k=%F0%9F%98%80") + end + end + + describe "#initialize" do + it "accepts a query string, with or without a leading ?" do + expect(described_class.new("a=1&a=2&b=").to_s).to eq("a=1&a=2&b=") + expect(described_class.new("?a=1").to_s).to eq("a=1") + expect(described_class.new("").to_s).to eq("") + end + + it "accepts a hash" do + expect(described_class.new({"a" => "1", "b" => "2"}).to_s).to eq("a=1&b=2") + end + + it "accepts a sequence of pairs" do + expect(described_class.new([%w[b 2], %w[a 1]]).to_s).to eq("b=2&a=1") + end + end + + describe ".encode_component" do + it "passes the WHATWG probe" do + expect(described_class.encode_component("a *~ b")).to eq("a+*%7E+b") + end + end + + it "is enumerable over pairs in order" do + params = described_class.new([%w[b 2], %w[a 1]]) + + expect(params.to_a).to eq([%w[b 2], %w[a 1]]) + expect(params.size).to eq(2) + expect(params).not_to be_empty + expect(described_class.new).to be_empty + end +end From b5c0a92c65a41efb427cb639dd7d105413cc9997 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 19:06:24 +0000 Subject: [PATCH 2/9] 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 Claude-Session: https://claude.ai/code/session_017p9TwdqYv9ccnv7nGvpfsp --- README.md | 30 ++++ codegen/lib/handlebars-helpers.ts | 11 +- lib/seam/request.rb | 64 +++++++- lib/seam/routes/access_codes.rb | 2 +- lib/seam/routes/access_codes_unmanaged.rb | 2 +- lib/seam/routes/access_grants.rb | 12 +- lib/seam/routes/access_grants_unmanaged.rb | 2 +- lib/seam/routes/access_methods.rb | 2 +- lib/seam/routes/acs_credentials.rb | 2 +- lib/seam/routes/acs_encoders.rb | 2 +- lib/seam/routes/acs_entrances.rb | 4 +- lib/seam/routes/acs_users.rb | 4 +- lib/seam/routes/action_attempts.rb | 2 +- lib/seam/routes/connect_webviews.rb | 2 +- lib/seam/routes/connected_accounts.rb | 2 +- lib/seam/routes/devices.rb | 6 +- lib/seam/routes/devices_unmanaged.rb | 2 +- lib/seam/routes/spaces.rb | 2 +- lib/seam/routes/thermostats.rb | 26 ++-- lib/seam/routes/thermostats_schedules.rb | 4 +- lib/seam/routes/user_identities.rb | 18 +-- lib/seam/routes/user_identities_unmanaged.rb | 2 +- lib/seam/routes/workspaces.rb | 2 +- spec/seam_client/url_search_params_spec.rb | 152 +++++++++++++++++++ spec/spec_helper.rb | 2 + spec/support/raw_request_recorder.rb | 87 +++++++++++ 26 files changed, 391 insertions(+), 55 deletions(-) create mode 100644 spec/seam_client/url_search_params_spec.rb create mode 100644 spec/support/raw_request_recorder.rb diff --git a/README.md b/README.md index 6092bc5..9ef197b 100644 --- a/README.md +++ b/README.md @@ -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) @@ -227,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. diff --git a/codegen/lib/handlebars-helpers.ts b/codegen/lib/handlebars-helpers.ts index 6444abb..9bc5e72 100644 --- a/codegen/lib/handlebars-helpers.ts +++ b/codegen/lib/handlebars-helpers.ts @@ -94,10 +94,13 @@ export const rubyParameterType = (parameter: Parameter): string => { parameter.format, parameter.format === 'number' && parameter.isInt, ) - return nullable(type, { - isOptional: !parameter.isRequired, - isNullable: parameter.isNullable, - }) + // Nullable and optional stay orthogonal: only a nullable parameter accepts + // the Seam::NULL sentinel (serialized as JSON null or an empty query + // value), and only an optional parameter accepts nil (omitted entirely). + const union = [type] + if (parameter.isNullable) union.push('Seam::Null') + if (!parameter.isRequired) union.push('nil') + return union.join(', ') } export const rubyParamDoc = ( diff --git a/lib/seam/request.rb b/lib/seam/request.rb index 480d47e..2c823c8 100644 --- a/lib/seam/request.rb +++ b/lib/seam/request.rb @@ -5,9 +5,54 @@ require_relative "defaults" require_relative "version" require_relative "paginator" +require_relative "url_search_params_serializer" module Seam module Http + # The Faraday params encoder that applies the Seam URL search params + # serializer to query params, so requests go out with the exact encoding, + # ordering, and number formatting the Seam API parses. Faraday builds the + # query string by calling this encoder after resolving the request path + # against the base URL, so the serialized query is emitted verbatim. + # + # Faraday's own encoders would silently disagree with the standard: the + # default NestedParamsEncoder turns {ids: []} into "ids%5B%5D" (a bare + # "ids[]" with no "="), which the API reads as no filter at all instead of + # an empty one. + module UrlSearchParamsEncoder + # Pairs decoded from a query string already present in the request + # path. Wrapping them lets {encode} pass them through verbatim instead + # of re-serializing them: a caller who built their own query string has + # chosen their own representation. + 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 + + 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 + module Request def self.create_faraday_client(endpoint, auth_headers, faraday_options = {}, faraday_retry_options = {}, timeout: nil) @@ -16,7 +61,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) @@ -33,6 +82,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 @@ -97,6 +147,18 @@ def seam_api_error_response?(env) end end + # Replaces every {Seam::NULL} sentinel in a JSON request body with nil, + # so it serializes to JSON null. Runs before the :json request + # middleware and copies the body rather than mutating the caller's + # payload. + 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| diff --git a/lib/seam/routes/access_codes.rb b/lib/seam/routes/access_codes.rb index 3e7dab2..af12abb 100644 --- a/lib/seam/routes/access_codes.rb +++ b/lib/seam/routes/access_codes.rb @@ -129,7 +129,7 @@ def get(access_code_id: nil, code: nil, device_id: nil) # @param customer_key [String, nil] Customer key for which you want to list access codes. # @param device_id [String, nil] ID of the device for which you want to list access codes. Specify `device_id`, `access_code_ids`, `access_method_id`, `access_grant_id`, or `access_grant_key`. # @param limit [Float, nil] Numerical limit on the number of access codes to return. - # @param page_cursor [String, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. + # @param page_cursor [String, Seam::Null, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. # @param search [String, nil] String for which to search. Filters returned access codes to include all records that satisfy a partial match using `name`, `code` or `access_code_id`. # @param user_identifier_key [String, nil] Your user ID for the user by which to filter access codes. # @return [Seam::Resources::AccessCode] OK diff --git a/lib/seam/routes/access_codes_unmanaged.rb b/lib/seam/routes/access_codes_unmanaged.rb index 3ef9469..3f6c04a 100644 --- a/lib/seam/routes/access_codes_unmanaged.rb +++ b/lib/seam/routes/access_codes_unmanaged.rb @@ -53,7 +53,7 @@ def get(access_code_id: nil, code: nil, device_id: nil) # Returns a list of all [unmanaged access codes](https://docs.seam.co/low-level-apis/smart-locks/access-codes/migrating-existing-access-codes). # @param device_id [String] ID of the device for which you want to list unmanaged access codes. # @param limit [Float, nil] Numerical limit on the number of unmanaged access codes to return. - # @param page_cursor [String, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. + # @param page_cursor [String, Seam::Null, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. # @param search [String, nil] String for which to search. Filters returned access codes to include all records that satisfy a partial match using `name`, `code` or `access_code_id`. # @param user_identifier_key [String, nil] Your user ID for the user by which to filter unmanaged access codes. # @return [Seam::Resources::UnmanagedAccessCode] OK diff --git a/lib/seam/routes/access_grants.rb b/lib/seam/routes/access_grants.rb index f0a1061..fa2a51f 100644 --- a/lib/seam/routes/access_grants.rb +++ b/lib/seam/routes/access_grants.rb @@ -20,12 +20,12 @@ def unmanaged # @param acs_entrance_ids [Array, nil] Set of IDs of the [entrances](https://docs.seam.co/api/acs/systems/list) to which access is being granted. # @param customization_profile_id [String, nil] ID of the customization profile to apply to the Access Grant and its access methods. # @param device_ids [Array, nil] Set of IDs of the [devices](https://docs.seam.co/api/devices/list) to which access is being granted. - # @param ends_at [String, nil] Date and time at which the validity of the new grant ends, in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. Must be a time in the future and after `starts_at`. + # @param ends_at [String, Seam::Null, nil] Date and time at which the validity of the new grant ends, in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. Must be a time in the future and after `starts_at`. # @param location [Hash, nil] # @deprecated location: Create a space first, then reference it using `space_ids`. # @param location_ids [Array, nil] # @deprecated location_ids: Use `space_ids`. - # @param name [String, nil] Name for the access grant. + # @param name [String, Seam::Null, nil] Name for the access grant. # @param reservation_key [String, nil] Reservation key for the access grant. # @param space_ids [Array, nil] Set of IDs of existing spaces to which access is being granted. # @param space_keys [Array, nil] Set of keys of existing spaces to which access is being granted. @@ -79,7 +79,7 @@ def get_related(access_grant_ids: nil, access_grant_keys: nil, exclude: nil, inc # Gets an Access Grant. # @param access_code_id [String, nil] ID of the access code by which you want to filter the list of Access Grants. # @param access_grant_ids [Array, nil] IDs of the access grants to retrieve. - # @param access_grant_key [String, nil] Filter Access Grants by access_grant_key. Use null to filter for Access Grants without an access_grant_key. + # @param access_grant_key [String, Seam::Null, nil] Filter Access Grants by access_grant_key. Use null to filter for Access Grants without an access_grant_key. # @param acs_entrance_id [String, nil] ID of the entrance by which you want to filter the list of Access Grants. # @param acs_system_id [String, nil] ID of the access system by which you want to filter the list of Access Grants. # @param customer_key [String, nil] Customer key for which you want to list access grants. @@ -87,7 +87,7 @@ def get_related(access_grant_ids: nil, access_grant_keys: nil, exclude: nil, inc # @param limit [Float, nil] Numerical limit on the number of access grants to return. # @param location_id [String, nil] # @deprecated location_id: Use `space_id`. - # @param page_cursor [String, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. + # @param page_cursor [String, Seam::Null, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. # @param reservation_key [String, nil] Filter Access Grants by reservation_key. # @param space_id [String, nil] ID of the space by which you want to filter the list of Access Grants. # @param user_identity_id [String, nil] ID of user identity by which you want to filter the list of Access Grants. @@ -111,8 +111,8 @@ def request_access_methods(access_grant_id:, requested_access_methods:) # Updates an existing Access Grant's time window. # @param access_grant_id [String, nil] ID of the Access Grant to update. Provide either `access_grant_id` or `access_grant_key`. # @param access_grant_key [String, nil] Key of the Access Grant to update. Provide either `access_grant_id` or `access_grant_key`. - # @param ends_at [Time, nil] Date and time at which the validity of the grant ends, in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. Must be a time in the future and after `starts_at`. - # @param name [String, nil] Display name for the access grant. + # @param ends_at [Time, Seam::Null, nil] Date and time at which the validity of the grant ends, in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. Must be a time in the future and after `starts_at`. + # @param name [String, Seam::Null, nil] Display name for the access grant. # @param starts_at [Time, nil] Date and time at which the validity of the grant starts, in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. # @return [nil] OK def update(access_grant_id: nil, access_grant_key: nil, ends_at: nil, name: nil, starts_at: nil) diff --git a/lib/seam/routes/access_grants_unmanaged.rb b/lib/seam/routes/access_grants_unmanaged.rb index 52742a0..03750d0 100644 --- a/lib/seam/routes/access_grants_unmanaged.rb +++ b/lib/seam/routes/access_grants_unmanaged.rb @@ -21,7 +21,7 @@ def get(access_grant_id:) # @param acs_entrance_id [String, nil] ID of the entrance by which you want to filter the list of unmanaged Access Grants. # @param acs_system_id [String, nil] ID of the access system by which you want to filter the list of unmanaged Access Grants. # @param limit [Float, nil] Numerical limit on the number of unmanaged access grants to return. - # @param page_cursor [String, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. + # @param page_cursor [String, Seam::Null, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. # @param reservation_key [String, nil] Filter unmanaged Access Grants by reservation_key. # @param user_identity_id [String, nil] ID of user identity by which you want to filter the list of unmanaged Access Grants. # @return [Seam::Resources::UnmanagedAccessGrant] OK diff --git a/lib/seam/routes/access_methods.rb b/lib/seam/routes/access_methods.rb index 3c01c5d..46fd0a5 100644 --- a/lib/seam/routes/access_methods.rb +++ b/lib/seam/routes/access_methods.rb @@ -80,7 +80,7 @@ def get_related(access_method_ids:, exclude: nil, include: nil) # @param acs_entrance_id [String, nil] ID of the entrance for which you want to retrieve all access methods that grant access to it. # @param device_id [String, nil] ID of the device by which to filter the returned access methods. Must be combined with `access_grant_id`, `access_grant_key`, or `acs_entrance_id`. # @param limit [Integer, nil] Maximum number of records to return per page. - # @param page_cursor [String, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. + # @param page_cursor [String, Seam::Null, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. # @param space_id [String, nil] ID of the space by which to filter the returned access methods. Must be combined with `access_grant_id`, `access_grant_key`, or `acs_entrance_id`. # @return [Seam::Resources::AccessMethod] OK def list(access_code_id: nil, access_grant_id: nil, access_grant_key: nil, acs_entrance_id: nil, device_id: nil, limit: nil, page_cursor: nil, space_id: nil) diff --git a/lib/seam/routes/acs_credentials.rb b/lib/seam/routes/acs_credentials.rb index a66f248..5c70928 100644 --- a/lib/seam/routes/acs_credentials.rb +++ b/lib/seam/routes/acs_credentials.rb @@ -65,7 +65,7 @@ def get(acs_credential_id:) # @param created_before [Time, nil] Date and time, in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format, before which events to return were created. # @param is_multi_phone_sync_credential [Boolean, nil] Indicates whether you want to retrieve only multi-phone sync credentials or non-multi-phone sync credentials. # @param limit [Float, nil] Number of credentials to return. - # @param page_cursor [String, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. + # @param page_cursor [String, Seam::Null, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. # @param search [String, nil] String for which to search. Filters returned credentials to include all records that satisfy a partial match using `display_name`, `code`, `card_number`, `acs_user_id` or `acs_credential_id`. # @return [Seam::Resources::AcsCredential] OK def list(acs_user_id: nil, acs_system_id: nil, user_identity_id: nil, created_before: nil, is_multi_phone_sync_credential: nil, limit: nil, page_cursor: nil, search: nil) diff --git a/lib/seam/routes/acs_encoders.rb b/lib/seam/routes/acs_encoders.rb index 7da5c6b..b7f84e4 100644 --- a/lib/seam/routes/acs_encoders.rb +++ b/lib/seam/routes/acs_encoders.rb @@ -41,7 +41,7 @@ def get(acs_encoder_id:) # @param acs_system_ids [Array, nil] IDs of the access systems for which you want to retrieve all encoders. # @param acs_encoder_ids [Array, nil] IDs of the encoders that you want to retrieve. # @param limit [Float, nil] Number of encoders to return. - # @param page_cursor [String, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. + # @param page_cursor [String, Seam::Null, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. # @return [Seam::Resources::AcsEncoder] OK def list(acs_system_id: nil, acs_system_ids: nil, acs_encoder_ids: nil, limit: nil, page_cursor: nil) res = @client.post("/acs/encoders/list", {acs_system_id: acs_system_id, acs_system_ids: acs_system_ids, acs_encoder_ids: acs_encoder_ids, limit: limit, page_cursor: page_cursor}.compact) diff --git a/lib/seam/routes/acs_entrances.rb b/lib/seam/routes/acs_entrances.rb index 5043518..6fb4f2b 100644 --- a/lib/seam/routes/acs_entrances.rb +++ b/lib/seam/routes/acs_entrances.rb @@ -38,9 +38,9 @@ def grant_access(acs_entrance_id:, acs_user_id: nil, user_identity_id: nil) # @param connected_account_id [String, nil] ID of the connected account for which you want to retrieve all entrances. # @param customer_key [String, nil] Customer key for which you want to list entrances. # @param limit [Integer, nil] Maximum number of records to return per page. - # @param location_id [String, nil] + # @param location_id [String, Seam::Null, nil] # @deprecated location_id: Use `space_id`. - # @param page_cursor [String, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. + # @param page_cursor [String, Seam::Null, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. # @param search [String, nil] String for which to search. Filters returned entrances to include all records that satisfy a partial match using `display_name`. # @param space_id [String, nil] ID of the space for which you want to list entrances. # @return [Seam::Resources::AcsEntrance] OK diff --git a/lib/seam/routes/acs_users.rb b/lib/seam/routes/acs_users.rb index a8d9f0b..f8e75f9 100644 --- a/lib/seam/routes/acs_users.rb +++ b/lib/seam/routes/acs_users.rb @@ -69,7 +69,7 @@ def get(acs_user_id: nil, acs_system_id: nil, user_identity_id: nil) # @param acs_system_id [String, nil] ID of the `acs_system` for which you want to retrieve all access system users. # @param created_before [Time, nil] Timestamp by which to limit returned access system users. Returns users created before this timestamp. # @param limit [Integer, nil] Maximum number of records to return per page. - # @param page_cursor [String, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. + # @param page_cursor [String, Seam::Null, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. # @param search [String, nil] String for which to search. Filters returned access system users to include all records that satisfy a partial match using `full_name`, `phone_number`, `email_address`, `acs_user_id`, `user_identity_id`, `user_identity_full_name` or `user_identity_phone_number`. # @param user_identity_email_address [String, nil] Email address of the user identity for which you want to retrieve all access system users. # @param user_identity_id [String, nil] ID of the user identity for which you want to retrieve all access system users. @@ -153,7 +153,7 @@ def unsuspend(acs_system_id: nil, acs_user_id: nil, user_identity_id: nil) end # Updates the properties of a specified [access system user](https://docs.seam.co/low-level-apis/access-systems/user-management). - # @param access_schedule [Hash, nil] `starts_at` and `ends_at` timestamps for the access system user's access. If you specify an `access_schedule`, you may include both `starts_at` and `ends_at`. If you omit `starts_at`, it defaults to the current time. `ends_at` is optional and must be a time in the future and after `starts_at`. + # @param access_schedule [Hash, Seam::Null, nil] `starts_at` and `ends_at` timestamps for the access system user's access. If you specify an `access_schedule`, you may include both `starts_at` and `ends_at`. If you omit `starts_at`, it defaults to the current time. `ends_at` is optional and must be a time in the future and after `starts_at`. # @param acs_system_id [String, nil] ID of the access system that you want to update. You can only provide acs_system_id with user_identity_id. # @param acs_user_id [String, nil] ID of the access system user that you want to update. You can only provide acs_user_id or user_identity_id. # @param email [String, nil] diff --git a/lib/seam/routes/action_attempts.rb b/lib/seam/routes/action_attempts.rb index 31cc10d..69b4eac 100644 --- a/lib/seam/routes/action_attempts.rb +++ b/lib/seam/routes/action_attempts.rb @@ -25,7 +25,7 @@ def get(action_attempt_id:, wait_for_action_attempt: nil) # @param action_attempt_ids [Array, nil] IDs of the action attempts that you want to retrieve. # @param device_id [String, nil] ID of the device to filter action attempts by. # @param limit [Integer, nil] Maximum number of records to return per page. - # @param page_cursor [String, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. + # @param page_cursor [String, Seam::Null, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. # @return [Seam::Resources::ActionAttempt] OK def list(action_attempt_ids: nil, device_id: nil, limit: nil, page_cursor: nil) res = @client.post("/action_attempts/list", {action_attempt_ids: action_attempt_ids, device_id: device_id, limit: limit, page_cursor: page_cursor}.compact) diff --git a/lib/seam/routes/connect_webviews.rb b/lib/seam/routes/connect_webviews.rb index cff03d2..02090b8 100644 --- a/lib/seam/routes/connect_webviews.rb +++ b/lib/seam/routes/connect_webviews.rb @@ -58,7 +58,7 @@ def get(connect_webview_id:) # @param custom_metadata_has [Hash, nil] Custom metadata pairs by which you want to [filter Connect Webviews](https://docs.seam.co/core-concepts/connect-webviews/filtering-connect-webviews-by-custom-metadata). Returns Connect Webviews with `custom_metadata` that contains all of the provided key:value pairs. # @param customer_key [String, nil] Customer key for which you want to list connect webviews. # @param limit [Float, nil] Maximum number of records to return per page. - # @param page_cursor [String, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. + # @param page_cursor [String, Seam::Null, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. # @param search [String, nil] String for which to search. Filters returned Connect Webviews to include all records that satisfy a partial match using `connect_webview_id`, `accepted_providers`, `custom_metadata`, or `customer_key`. # @param user_identifier_key [String, nil] Your user ID for the user by which you want to filter Connect Webviews. # @return [Seam::Resources::ConnectWebview] OK diff --git a/lib/seam/routes/connected_accounts.rb b/lib/seam/routes/connected_accounts.rb index 273256d..69228b0 100644 --- a/lib/seam/routes/connected_accounts.rb +++ b/lib/seam/routes/connected_accounts.rb @@ -43,7 +43,7 @@ def get(connected_account_id: nil, email: nil) # @param custom_metadata_has [Hash, nil] Custom metadata pairs by which you want to filter connected accounts. Returns connected accounts with `custom_metadata` that contains all of the provided key:value pairs. # @param customer_key [String, nil] Customer key by which you want to filter connected accounts. # @param limit [Integer, nil] Maximum number of records to return per page. - # @param page_cursor [String, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. + # @param page_cursor [String, Seam::Null, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. # @param search [String, nil] String for which to search. Filters returned connected accounts to include all records that satisfy a partial match using `connected_account_id`, `account_type`, `customer_key`, `custom_metadata`, `user_identifier.username`, `user_identifier.email` or `user_identifier.phone`. # @param space_id [String, nil] ID of the space by which you want to filter connected accounts. # @param user_identifier_key [String, nil] Your user ID for the user by which you want to filter connected accounts. diff --git a/lib/seam/routes/devices.rb b/lib/seam/routes/devices.rb index 1d64d41..b017281 100644 --- a/lib/seam/routes/devices.rb +++ b/lib/seam/routes/devices.rb @@ -44,10 +44,10 @@ def get(device_id: nil, name: nil) # @param device_types [Array, nil] Array of device types for which you want to list devices. # @param limit [Float, nil] Numerical limit on the number of devices to return. # @param manufacturer [String, nil] Manufacturer for which you want to list devices. - # @param page_cursor [String, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. + # @param page_cursor [String, Seam::Null, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. # @param search [String, nil] String for which to search. Filters returned devices to include all records that satisfy a partial match using `device_id` (full or partial UUID prefix, minimum 4 characters), `connected_account_id`, `display_name`, `custom_metadata` or `location.location_name`. # @param space_id [String, nil] ID of the space for which you want to list devices. - # @param unstable_location_id [String, nil] + # @param unstable_location_id [String, Seam::Null, nil] # @deprecated unstable_location_id: Use `space_id`. # @param user_identifier_key [String, nil] Your own internal user ID for the user for which you want to list devices. # @return [Seam::Resources::Device] OK @@ -86,7 +86,7 @@ def report_provider_metadata(devices:) # @param backup_access_code_pool_enabled [Boolean, nil] Indicates whether the device's [backup access code pool](https://docs.seam.co/low-level-apis/smart-locks/access-codes/backup-access-codes) is enabled. Set to `false` to disable the pool: Seam stops refilling it and removes any backup codes that have not yet been pulled into active use. # @param custom_metadata [Hash, nil] Custom metadata that you want to associate with the device. Supports up to 50 JSON key:value pairs. [Adding custom metadata to a device](https://docs.seam.co/core-concepts/devices/adding-custom-metadata-to-a-device) enables you to store custom information, like customer details or internal IDs from your application. Then, you can [filter devices by the desired metadata](https://docs.seam.co/core-concepts/devices/filtering-devices-by-custom-metadata). # @param is_managed [Boolean, nil] Indicates whether the device is managed. To unmanage a device, set `is_managed` to `false`. - # @param name [String, nil] Name for the device. + # @param name [String, Seam::Null, nil] Name for the device. # @param properties [Hash, nil] # @return [nil] OK def update(device_id:, backup_access_code_pool_enabled: nil, custom_metadata: nil, is_managed: nil, name: nil, properties: nil) diff --git a/lib/seam/routes/devices_unmanaged.rb b/lib/seam/routes/devices_unmanaged.rb index 8f61a77..143832e 100644 --- a/lib/seam/routes/devices_unmanaged.rb +++ b/lib/seam/routes/devices_unmanaged.rb @@ -39,7 +39,7 @@ def get(device_id: nil, name: nil) # @param device_types [Array, nil] Array of device types for which you want to list devices. # @param limit [Float, nil] Numerical limit on the number of devices to return. # @param manufacturer [String, nil] Manufacturer for which you want to list devices. - # @param page_cursor [String, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. + # @param page_cursor [String, Seam::Null, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. # @param search [String, nil] String for which to search. Filters returned devices to include all records that satisfy a partial match using `device_id` (full or partial UUID prefix, minimum 4 characters), `connected_account_id`, `display_name`, `custom_metadata` or `location.location_name`. # @return [Seam::Resources::UnmanagedDevice] OK def list(connect_webview_id: nil, connected_account_id: nil, connected_account_ids: nil, created_before: nil, customer_key: nil, device_ids: nil, device_type: nil, device_types: nil, limit: nil, manufacturer: nil, page_cursor: nil, search: nil) diff --git a/lib/seam/routes/spaces.rb b/lib/seam/routes/spaces.rb index 7f73e93..c362696 100644 --- a/lib/seam/routes/spaces.rb +++ b/lib/seam/routes/spaces.rb @@ -95,7 +95,7 @@ def get_related(exclude: nil, include: nil, space_ids: nil, space_keys: nil) # Returns a list of all spaces. # @param customer_key [String, nil] Customer key for which you want to list spaces. # @param limit [Float, nil] Maximum number of records to return per page. - # @param page_cursor [String, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. + # @param page_cursor [String, Seam::Null, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. # @param search [String, nil] String for which to search. Filters returned spaces to include all records that satisfy a partial match using `name`, `space_key`, or `customer_key`. # @param space_key [String, nil] Filter spaces by space_key. # @return [Seam::Resources::Space] OK diff --git a/lib/seam/routes/thermostats.rb b/lib/seam/routes/thermostats.rb index 89e77ae..61948f6 100644 --- a/lib/seam/routes/thermostats.rb +++ b/lib/seam/routes/thermostats.rb @@ -60,7 +60,7 @@ def cool(device_id:, cooling_set_point_celsius: nil, cooling_set_point_fahrenhei # @param hvac_mode_setting [String, nil] Desired [HVAC mode](https://docs.seam.co/capability-guides/thermostats/understanding-thermostat-concepts/hvac-mode) setting, such as `heat`, `cool`, `heat_cool`, or `off`. # @param manual_override_allowed [Boolean, nil] Indicates whether a person at the thermostat or using the API can change the thermostat's settings. # @deprecated manual_override_allowed: Use 'thermostat_schedule.is_override_allowed' - # @param name [String, nil] User-friendly name to identify the [climate preset](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-climate-presets). + # @param name [String, Seam::Null, nil] User-friendly name to identify the [climate preset](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-climate-presets). # @return [nil] OK def create_climate_preset(climate_preset_key:, device_id:, climate_preset_mode: nil, cooling_set_point_celsius: nil, cooling_set_point_fahrenheit: nil, ecobee_metadata: nil, fan_mode_setting: nil, heating_set_point_celsius: nil, heating_set_point_fahrenheit: nil, hvac_mode_setting: nil, manual_override_allowed: nil, name: nil) @client.post("/thermostats/create_climate_preset", {climate_preset_key: climate_preset_key, device_id: device_id, climate_preset_mode: climate_preset_mode, cooling_set_point_celsius: cooling_set_point_celsius, cooling_set_point_fahrenheit: cooling_set_point_fahrenheit, ecobee_metadata: ecobee_metadata, fan_mode_setting: fan_mode_setting, heating_set_point_celsius: heating_set_point_celsius, heating_set_point_fahrenheit: heating_set_point_fahrenheit, hvac_mode_setting: hvac_mode_setting, manual_override_allowed: manual_override_allowed, name: name}.compact) @@ -173,10 +173,10 @@ def set_hvac_mode(device_id:, hvac_mode_setting:, cooling_set_point_celsius: nil # Sets a [temperature threshold](https://docs.seam.co/capability-guides/thermostats/setting-and-monitoring-temperature-thresholds) for a specified thermostat. Seam emits a `thermostat.temperature_threshold_exceeded` event and adds a warning on a thermostat if it reports a temperature outside the threshold range. # @param device_id [String] ID of the thermostat device for which you want to set a temperature threshold. - # @param lower_limit_celsius [Float, nil] Lower temperature limit in in °C. Seam alerts you if the reported temperature is lower than this value. You can specify either `lower_limit` but not both. - # @param lower_limit_fahrenheit [Float, nil] Lower temperature limit in in °F. Seam alerts you if the reported temperature is lower than this value. You can specify either `lower_limit` but not both. - # @param upper_limit_celsius [Float, nil] Upper temperature limit in in °C. Seam alerts you if the reported temperature is higher than this value. You can specify either `upper_limit` but not both. - # @param upper_limit_fahrenheit [Float, nil] Upper temperature limit in in °C. Seam alerts you if the reported temperature is higher than this value. You can specify either `upper_limit` but not both. + # @param lower_limit_celsius [Float, Seam::Null, nil] Lower temperature limit in in °C. Seam alerts you if the reported temperature is lower than this value. You can specify either `lower_limit` but not both. + # @param lower_limit_fahrenheit [Float, Seam::Null, nil] Lower temperature limit in in °F. Seam alerts you if the reported temperature is lower than this value. You can specify either `lower_limit` but not both. + # @param upper_limit_celsius [Float, Seam::Null, nil] Upper temperature limit in in °C. Seam alerts you if the reported temperature is higher than this value. You can specify either `upper_limit` but not both. + # @param upper_limit_fahrenheit [Float, Seam::Null, nil] Upper temperature limit in in °C. Seam alerts you if the reported temperature is higher than this value. You can specify either `upper_limit` but not both. # @return [nil] OK def set_temperature_threshold(device_id:, lower_limit_celsius: nil, lower_limit_fahrenheit: nil, upper_limit_celsius: nil, upper_limit_fahrenheit: nil) @client.patch("/thermostats/set_temperature_threshold", {device_id: device_id, lower_limit_celsius: lower_limit_celsius, lower_limit_fahrenheit: lower_limit_fahrenheit, upper_limit_celsius: upper_limit_celsius, upper_limit_fahrenheit: upper_limit_fahrenheit}.compact) @@ -197,7 +197,7 @@ def set_temperature_threshold(device_id:, lower_limit_celsius: nil, lower_limit_ # @param hvac_mode_setting [String, nil] Desired [HVAC mode](https://docs.seam.co/capability-guides/thermostats/understanding-thermostat-concepts/hvac-mode) setting, such as `heat`, `cool`, `heat_cool`, or `off`. # @param manual_override_allowed [Boolean, nil] Indicates whether a person at the thermostat can change the thermostat's settings. See [Specifying Manual Override Permissions](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-thermostat-schedules#specifying-manual-override-permissions). # @deprecated manual_override_allowed: Use 'thermostat_schedule.is_override_allowed' - # @param name [String, nil] User-friendly name to identify the [climate preset](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-climate-presets). + # @param name [String, Seam::Null, nil] User-friendly name to identify the [climate preset](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-climate-presets). # @return [nil] OK def update_climate_preset(climate_preset_key:, device_id:, climate_preset_mode: nil, cooling_set_point_celsius: nil, cooling_set_point_fahrenheit: nil, ecobee_metadata: nil, fan_mode_setting: nil, heating_set_point_celsius: nil, heating_set_point_fahrenheit: nil, hvac_mode_setting: nil, manual_override_allowed: nil, name: nil) @client.patch("/thermostats/update_climate_preset", {climate_preset_key: climate_preset_key, device_id: device_id, climate_preset_mode: climate_preset_mode, cooling_set_point_celsius: cooling_set_point_celsius, cooling_set_point_fahrenheit: cooling_set_point_fahrenheit, ecobee_metadata: ecobee_metadata, fan_mode_setting: fan_mode_setting, heating_set_point_celsius: heating_set_point_celsius, heating_set_point_fahrenheit: heating_set_point_fahrenheit, hvac_mode_setting: hvac_mode_setting, manual_override_allowed: manual_override_allowed, name: name}.compact) @@ -207,13 +207,13 @@ def update_climate_preset(climate_preset_key:, device_id:, climate_preset_mode: # Updates the thermostat weekly program for a thermostat device. To configure a weekly program, specify the ID of the daily program that you want to use for each day of the week. When you update a weekly program, the set of programs that you specify overwrites any previous weekly program for the thermostat. # @param device_id [String] ID of the thermostat device for which you want to update the weekly program. - # @param friday_program_id [String, nil] ID of the thermostat daily program to run on Fridays. - # @param monday_program_id [String, nil] ID of the thermostat daily program to run on Mondays. - # @param saturday_program_id [String, nil] ID of the thermostat daily program to run on Saturdays. - # @param sunday_program_id [String, nil] ID of the thermostat daily program to run on Sundays. - # @param thursday_program_id [String, nil] ID of the thermostat daily program to run on Thursdays. - # @param tuesday_program_id [String, nil] ID of the thermostat daily program to run on Tuesdays. - # @param wednesday_program_id [String, nil] ID of the thermostat daily program to run on Wednesdays. + # @param friday_program_id [String, Seam::Null, nil] ID of the thermostat daily program to run on Fridays. + # @param monday_program_id [String, Seam::Null, nil] ID of the thermostat daily program to run on Mondays. + # @param saturday_program_id [String, Seam::Null, nil] ID of the thermostat daily program to run on Saturdays. + # @param sunday_program_id [String, Seam::Null, nil] ID of the thermostat daily program to run on Sundays. + # @param thursday_program_id [String, Seam::Null, nil] ID of the thermostat daily program to run on Thursdays. + # @param tuesday_program_id [String, Seam::Null, nil] ID of the thermostat daily program to run on Tuesdays. + # @param wednesday_program_id [String, Seam::Null, nil] ID of the thermostat daily program to run on Wednesdays. # @return [Seam::Resources::ActionAttempt] OK def update_weekly_program(device_id:, friday_program_id: nil, monday_program_id: nil, saturday_program_id: nil, sunday_program_id: nil, thursday_program_id: nil, tuesday_program_id: nil, wednesday_program_id: nil, wait_for_action_attempt: nil) res = @client.post("/thermostats/update_weekly_program", {device_id: device_id, friday_program_id: friday_program_id, monday_program_id: monday_program_id, saturday_program_id: saturday_program_id, sunday_program_id: sunday_program_id, thursday_program_id: thursday_program_id, tuesday_program_id: tuesday_program_id, wednesday_program_id: wednesday_program_id}.compact) diff --git a/lib/seam/routes/thermostats_schedules.rb b/lib/seam/routes/thermostats_schedules.rb index f808b67..d065da5 100644 --- a/lib/seam/routes/thermostats_schedules.rb +++ b/lib/seam/routes/thermostats_schedules.rb @@ -14,7 +14,7 @@ def initialize(client:, defaults:) # @param ends_at [String] Date and time at which the new thermostat schedule ends, in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. # @param starts_at [String] Date and time at which the new thermostat schedule starts, in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. # @param is_override_allowed [Boolean, nil] Indicates whether a person at the thermostat or using the API can change the thermostat's settings while the new schedule is active. See also [Specifying Manual Override Permissions](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-thermostat-schedules#specifying-manual-override-permissions). - # @param max_override_period_minutes [Integer, nil] Number of minutes for which a person at the thermostat or using the API can change the thermostat's settings after the activation of the scheduled climate preset. See also [Specifying Manual Override Permissions](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-thermostat-schedules#specifying-manual-override-permissions). + # @param max_override_period_minutes [Integer, Seam::Null, nil] Number of minutes for which a person at the thermostat or using the API can change the thermostat's settings after the activation of the scheduled climate preset. See also [Specifying Manual Override Permissions](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-thermostat-schedules#specifying-manual-override-permissions). # @param name [String, nil] Name of the thermostat schedule. # @return [Seam::Resources::ThermostatSchedule] OK def create(climate_preset_key:, device_id:, ends_at:, starts_at:, is_override_allowed: nil, max_override_period_minutes: nil, name: nil) @@ -56,7 +56,7 @@ def list(device_id:, user_identifier_key: nil) # @param climate_preset_key [String, nil] Key of the [climate preset](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-climate-presets) to use for the thermostat schedule. # @param ends_at [String, nil] Date and time at which the thermostat schedule ends, in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. # @param is_override_allowed [Boolean, nil] Indicates whether a person at the thermostat or using the API can change the thermostat's settings while the schedule is active. See also [Specifying Manual Override Permissions](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-thermostat-schedules#specifying-manual-override-permissions). - # @param max_override_period_minutes [Integer, nil] Number of minutes for which a person at the thermostat or using the API can change the thermostat's settings after the activation of the scheduled climate preset. See also [Specifying Manual Override Permissions](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-thermostat-schedules#specifying-manual-override-permissions). + # @param max_override_period_minutes [Integer, Seam::Null, nil] Number of minutes for which a person at the thermostat or using the API can change the thermostat's settings after the activation of the scheduled climate preset. See also [Specifying Manual Override Permissions](https://docs.seam.co/capability-guides/thermostats/creating-and-managing-thermostat-schedules#specifying-manual-override-permissions). # @param name [String, nil] Name of the thermostat schedule. # @param starts_at [String, nil] Date and time at which the thermostat schedule starts, in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html) format. # @return [nil] OK diff --git a/lib/seam/routes/user_identities.rb b/lib/seam/routes/user_identities.rb index e7d6f04..dc96178 100644 --- a/lib/seam/routes/user_identities.rb +++ b/lib/seam/routes/user_identities.rb @@ -29,10 +29,10 @@ def add_acs_user(acs_user_id:, user_identity_id: nil, user_identity_key: nil) # Creates a new [user identity](https://docs.seam.co/capability-guides/mobile-access/managing-mobile-app-user-accounts-with-user-identities#what-is-a-user-identity). # @param acs_system_ids [Array, nil] List of access system IDs to associate with the new user identity through access system users. If there's no user with the same email address or phone number in the specified access systems, a new access system user is created. If there is an existing user with the same email or phone number in the specified access systems, the user is linked to the user identity. - # @param email_address [String, nil] Unique email address for the new user identity. - # @param full_name [String, nil] Full name of the user associated with the new user identity. - # @param phone_number [String, nil] Unique phone number for the new user identity in E.164 format (for example, +15555550100). - # @param user_identity_key [String, nil] Unique key for the new user identity. + # @param email_address [String, Seam::Null, nil] Unique email address for the new user identity. + # @param full_name [String, Seam::Null, nil] Full name of the user associated with the new user identity. + # @param phone_number [String, Seam::Null, nil] Unique phone number for the new user identity in E.164 format (for example, +15555550100). + # @param user_identity_key [String, Seam::Null, nil] Unique key for the new user identity. # @return [Seam::Resources::UserIdentity] OK def create(acs_system_ids: nil, email_address: nil, full_name: nil, phone_number: nil, user_identity_key: nil) res = @client.post("/user_identities/create", {acs_system_ids: acs_system_ids, email_address: email_address, full_name: full_name, phone_number: phone_number, user_identity_key: user_identity_key}.compact) @@ -88,7 +88,7 @@ def grant_access_to_device(device_id:, user_identity_id:) # @param created_before [Time, nil] Timestamp by which to limit returned user identities. Returns user identities created before this timestamp. # @param credential_manager_acs_system_id [String, nil] `acs_system_id` of the credential manager by which you want to filter the list of user identities. # @param limit [Integer, nil] Maximum number of records to return per page. - # @param page_cursor [String, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. + # @param page_cursor [String, Seam::Null, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. # @param search [String, nil] String for which to search. Filters returned user identities to include all records that satisfy a partial match using `full_name`, `phone_number`, `email_address` or `user_identity_id`. # @param user_identity_ids [Array, nil] Array of user identity IDs by which to filter the list of user identities. # @return [Seam::Resources::UserIdentity] OK @@ -156,10 +156,10 @@ def revoke_access_to_device(device_id:, user_identity_id:) # Updates a specified [user identity](https://docs.seam.co/capability-guides/mobile-access/managing-mobile-app-user-accounts-with-user-identities#what-is-a-user-identity). # @param user_identity_id [String] ID of the user identity that you want to update. - # @param email_address [String, nil] Unique email address for the user identity. - # @param full_name [String, nil] Full name of the user associated with the user identity. - # @param phone_number [String, nil] Unique phone number for the user identity. - # @param user_identity_key [String, nil] Unique key for the user identity. + # @param email_address [String, Seam::Null, nil] Unique email address for the user identity. + # @param full_name [String, Seam::Null, nil] Full name of the user associated with the user identity. + # @param phone_number [String, Seam::Null, nil] Unique phone number for the user identity. + # @param user_identity_key [String, Seam::Null, nil] Unique key for the user identity. # @return [nil] OK def update(user_identity_id:, email_address: nil, full_name: nil, phone_number: nil, user_identity_key: nil) @client.patch("/user_identities/update", {user_identity_id: user_identity_id, email_address: email_address, full_name: full_name, phone_number: phone_number, user_identity_key: user_identity_key}.compact) diff --git a/lib/seam/routes/user_identities_unmanaged.rb b/lib/seam/routes/user_identities_unmanaged.rb index 30f972c..910db0f 100644 --- a/lib/seam/routes/user_identities_unmanaged.rb +++ b/lib/seam/routes/user_identities_unmanaged.rb @@ -20,7 +20,7 @@ def get(user_identity_id:) # Returns a list of all unmanaged [user identities](https://docs.seam.co/capability-guides/mobile-access/managing-mobile-app-user-accounts-with-user-identities#what-is-a-user-identity) (where is_managed = false). # @param created_before [Time, nil] Timestamp by which to limit returned unmanaged user identities. Returns user identities created before this timestamp. # @param limit [Integer, nil] Maximum number of records to return per page. - # @param page_cursor [String, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. + # @param page_cursor [String, Seam::Null, nil] Identifies the specific page of results to return, obtained from the previous page's `next_page_cursor`. # @param search [String, nil] String for which to search. Filters returned unmanaged user identities to include all records that satisfy a partial match using `full_name`, `phone_number`, `email_address`, `user_identity_id` or `acs_system_id`. # @return [Seam::Resources::UnmanagedUserIdentity] OK def list(created_before: nil, limit: nil, page_cursor: nil, search: nil) diff --git a/lib/seam/routes/workspaces.rb b/lib/seam/routes/workspaces.rb index bf9badd..40505d2 100644 --- a/lib/seam/routes/workspaces.rb +++ b/lib/seam/routes/workspaces.rb @@ -13,7 +13,7 @@ def initialize(client:, defaults:) # Creates a new [workspace](https://docs.seam.co/core-concepts/workspaces). # @param name [String] Name of the new workspace. # @param company_name [String, nil] Company name for the new workspace. - # @param connect_partner_name [String, nil] Connect partner name for the new workspace. + # @param connect_partner_name [String, Seam::Null, nil] Connect partner name for the new workspace. # @deprecated connect_partner_name: Use `company_name` instead. # @param connect_webview_customization [Hash, nil] [Connect Webview](https://docs.seam.co/core-concepts/connect-webviews) customizations for the new workspace. See also [Customize the Look and Feel of Your Connect Webviews](https://docs.seam.co/core-concepts/connect-webviews/customizing-connect-webviews#customize-the-look-and-feel-of-your-connect-webviews). # @param is_sandbox [Boolean, nil] Indicates whether the new workspace is a [sandbox workspace](https://docs.seam.co/core-concepts/workspaces#sandbox-workspaces). diff --git a/spec/seam_client/url_search_params_spec.rb b/spec/seam_client/url_search_params_spec.rb new file mode 100644 index 0000000..56ae9f2 --- /dev/null +++ b/spec/seam_client/url_search_params_spec.rb @@ -0,0 +1,152 @@ +# frozen_string_literal: true + +# These specs assert on the raw query string the SDK puts on the wire, not a +# re-parsed version of it, which would hide encoding differences. +RSpec.describe Seam::Http::Request, recorder: true do + describe "query params" do + it "serializes arrays by repeating the name, preserving order" do + seam.client.get("/devices/list", {device_ids: %w[device-2 device-1]}) + + expect(recorder.requests.first.query).to eq("device_ids=device-2&device_ids=device-1") + end + + it "serializes an empty array as a single pair with an empty value" do + seam.client.get("/devices/list", {device_ids: []}) + + expect(recorder.requests.first.query).to eq("device_ids=") + end + + it "serializes nested objects by joining keys with dots" do + seam.client.get("/devices/list", {custom_metadata_has: {internal_account_id: "user-1"}}) + + expect(recorder.requests.first.query).to eq("custom_metadata_has.internal_account_id=user-1") + end + + it "encodes with the WHATWG form serializer, escaping ~ and passing * through" do + seam.client.get("/devices/list", {search: "a *~ b"}) + + expect(recorder.requests.first.query).to eq("search=a+*%7E+b") + end + + it "sorts pairs by name" do + seam.client.get("/devices/list", {limit: 5, device_type: "august_lock", search: "x"}) + + expect(recorder.requests.first.query).to eq("device_type=august_lock&limit=5&search=x") + end + + it "omits nil params entirely" do + seam.client.get("/devices/list", {search: nil, limit: 1}) + + expect(recorder.requests.first.query).to eq("limit=1") + end + + it "serializes the NULL sentinel as an empty value" do + seam.client.get("/devices/list", {search: Seam::NULL}) + + expect(recorder.requests.first.query).to eq("search=") + end + + it "emits no bare ? when nothing serializes" do + seam.client.get("/devices/list", {search: nil}) + seam.client.get("/devices/list") + + expect(recorder.requests.map(&:target)).to eq(["/devices/list", "/devices/list"]) + end + + it "emits the serialized query verbatim through base URL resolution" do + seam.client.get("/devices/list", {device_ids: [], search: "a *~ b"}) + + expect(recorder.requests.first.target).to eq("/devices/list?device_ids=&search=a+*%7E+b") + end + + it "passes a query string already built by the caller through verbatim" do + seam.client.get("/devices/list?already=built&ids=") + + expect(recorder.requests.first.query).to eq("already=built&ids=") + end + + it "merges params into a query string already built by the caller" do + seam.client.get("/devices/list?built=1", {added: "a b"}) + + expect(recorder.requests.first.query).to eq("added=a+b&built=1") + end + + it "raises the typed error before any request is sent" do + expect { + seam.client.get("/devices/list", {device_ids: ["a", ""]}) + }.to raise_error(Seam::UnserializableParamError) do |error| + expect(error.param_name).to eq("device_ids") + end + + expect(recorder.requests).to be_empty + end + + it "serializes query params on DELETE requests" do + seam.client.delete("/acs/access_groups/delete", {acs_access_group_id: "group-1"}) + + request = recorder.requests.first + expect(request.method).to eq("DELETE") + expect(request.query).to eq("acs_access_group_id=group-1") + end + end + + describe "request bodies" do + it "sends the NULL sentinel as JSON null on POST" do + seam.client.post("/thermostats/update", {device_id: "device-1", default_climate_setting: Seam::NULL}) + + request = recorder.requests.first + expect(request.method).to eq("POST") + expect(JSON.parse(request.body)).to eq( + "device_id" => "device-1", + "default_climate_setting" => nil + ) + end + + it "replaces the sentinel in nested hashes and arrays" do + seam.client.post("/x", {a: {b: Seam::NULL}, c: [Seam::NULL, 1]}) + + expect(JSON.parse(recorder.requests.first.body)).to eq( + "a" => {"b" => nil}, + "c" => [nil, 1] + ) + end + + it "does not mutate the caller's payload" do + payload = {device_id: "device-1", name: Seam::NULL} + seam.client.post("/x", payload) + + expect(payload[:name]).to equal(Seam::NULL) + end + + it "sends the sentinel as JSON null on PUT and PATCH" do + seam.client.put("/x", {name: Seam::NULL}) + seam.client.patch("/x", {name: Seam::NULL}) + + expect(recorder.requests.map(&:method)).to eq(%w[PUT PATCH]) + recorder.requests.each do |request| + expect(JSON.parse(request.body)).to eq("name" => nil) + end + end + end + + describe "generated routes" do + it "serializes a generated GET route end to end" do + recorder.respond_with({device: {device_id: "device-1"}}.to_json) + + device = seam.locks.get(device_id: "device-1") + + request = recorder.requests.first + expect(request.method).to eq("GET") + expect(request.target).to eq("/locks/get?device_id=device-1") + expect(device.device_id).to eq("device-1") + end + + it "omits absent optional params from a generated route" do + recorder.respond_with({device: {device_id: "device-1"}}.to_json) + + seam.locks.get(device_id: "device-1", name: nil) + + expect(recorder.requests.first.target).to eq("/locks/get?device_id=device-1") + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 294aad5..af6fe0f 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -17,6 +17,7 @@ require "webmock/rspec" require "support/fake_seam_connect" +require "support/raw_request_recorder" SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter.new([ SimpleCov::Formatter::HTMLFormatter, @@ -38,4 +39,5 @@ end config.include_context "with fake seam connect", fake: true + config.include_context "with raw request recorder", recorder: true end diff --git a/spec/support/raw_request_recorder.rb b/spec/support/raw_request_recorder.rb new file mode 100644 index 0000000..481753b --- /dev/null +++ b/spec/support/raw_request_recorder.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +require "socket" + +# A local HTTP server that records the exact bytes of each request line and +# body. WebMock re-parses URLs, which would hide encoding differences, so the +# URL search params specs assert against the raw request line instead. +class RawRequestRecorder + RecordedRequest = Struct.new(:method, :target, :body) do + # The raw query string, exactly as it appeared on the request line. + def query + _, query = target.split("?", 2) + query + end + + def path + target.split("?", 2).first + end + end + + attr_reader :endpoint, :requests + + def initialize + @server = TCPServer.new("127.0.0.1", 0) + @endpoint = "http://127.0.0.1:#{@server.addr[1]}" + @requests = [] + @response_body = "{}" + @thread = Thread.new { serve } + @thread.abort_on_exception = true + end + + def respond_with(body) + @response_body = body + end + + def stop + @thread.kill + @server.close + end + + private + + def serve + loop do + socket = @server.accept + begin + handle(socket) + ensure + socket.close + end + end + end + + def handle(socket) + request_line = socket.gets + return if request_line.nil? + + method, target, = request_line.split(" ") + + content_length = 0 + while (line = socket.gets) + break if line == "\r\n" + + name, value = line.split(":", 2) + content_length = value.to_i if name.casecmp?("content-length") + end + + body = content_length.positive? ? socket.read(content_length) : nil + @requests << RecordedRequest.new(method, target, body) + + socket.write( + "HTTP/1.1 200 OK\r\n" \ + "Content-Type: application/json\r\n" \ + "Content-Length: #{@response_body.bytesize}\r\n" \ + "Connection: close\r\n" \ + "\r\n" \ + "#{@response_body}" + ) + end +end + +RSpec.shared_context "with raw request recorder" do + let(:recorder) { RawRequestRecorder.new } + let(:seam) { Seam.new(api_key: "seam_some_api_key", endpoint: recorder.endpoint) } + + after { recorder.stop } +end From 3df6b8b6b739096867608836e9e97791ebf2a695 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 19:06:35 +0000 Subject: [PATCH 3/9] 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 Claude-Session: https://claude.ai/code/session_017p9TwdqYv9ccnv7nGvpfsp --- README.md | 17 ++ dev/conformance/generate_fixture.rb | 278 ++++++++++++++++++++++++++++ dev/conformance/roundtrip.mjs | 161 ++++++++++++++++ dev/conformance/run | 47 +++++ dev/conformance/run_reference.mjs | 41 ++++ dev/conformance/run_ruby.rb | 59 ++++++ package-lock.json | 27 +++ package.json | 2 + 8 files changed, 632 insertions(+) create mode 100644 dev/conformance/generate_fixture.rb create mode 100644 dev/conformance/roundtrip.mjs create mode 100755 dev/conformance/run create mode 100644 dev/conformance/run_reference.mjs create mode 100644 dev/conformance/run_ruby.rb diff --git a/README.md b/README.md index 9ef197b..70a80e1 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ accurate and fully typed. - [URL search params serialization](#url-search-params-serialization) - [Development and Testing](#development-and-testing) - [Quickstart](#quickstart) + - [URL search params conformance](#url-search-params-conformance) - [Source code](#source-code) - [Requirements](#requirements) - [Publishing](#publishing) @@ -585,6 +586,22 @@ $ bundle exec rake -T [rake]: https://ruby.github.io/rake/ +### URL search params conformance + +The URL search params serializer is verified byte for byte against the +TypeScript reference implementation over a generated fixture of hand-built, +structural-fuzz, and float-fuzz cases, and round-tripped through +[@seamapi/url-search-params-parser][parser] in strict mode. +The harness needs Node and the reference implementation, +which gem consumers should not, +so it lives in `dev/conformance` outside the shipped gem. +Run it with + +``` +$ npm install +$ dev/conformance/run +``` + ### Source code The [source code] is hosted on GitHub. diff --git a/dev/conformance/generate_fixture.rb b/dev/conformance/generate_fixture.rb new file mode 100644 index 0000000..b4e2d01 --- /dev/null +++ b/dev/conformance/generate_fixture.rb @@ -0,0 +1,278 @@ +# frozen_string_literal: true + +# Generates the shared conformance fixture read by both the TypeScript +# reference implementation (run_reference.mjs) and the Ruby port (run_ruby.rb). +# +# Values that JSON cannot express directly use tagged objects so a date does +# not round-trip through the fixture as a string: +# +# {"$float" => "1.5"} -> Float / JavaScript number (also "NaN", +# "Infinity", and "-Infinity") +# {"$bigint" => "123"} -> Integer / JavaScript BigInt +# {"$date" => 1700000000123} -> Time / JavaScript Date (integer epoch +# milliseconds; JavaScript Dates cannot carry +# sub-millisecond precision, so truncation is +# covered by a Ruby-only unit test instead) +# {"$undefined" => true} -> nil / JavaScript undefined +# JSON null -> Seam::NULL / JavaScript null +# +# Keys never start with "$" except inside tag objects. +# +# Usage: ruby generate_fixture.rb FIXTURE_PATH [SEED] + +require "json" + +fixture_path = ARGV.fetch(0) +seed = (ARGV[1] || Random.new_seed).to_i +random = Random.new(seed) + +FLOAT = ->(value) { {"$float" => value.to_s} } +BIGINT = ->(value) { {"$bigint" => value.to_s} } +DATE = ->(ms) { {"$date" => ms} } +UNDEFINED = {"$undefined" => true}.freeze + +cases = [] + +# --- Tier 1: hand-built cases covering every branch of the standard. --- + +hand_built = [ + # Basic scalars and sorting. + {"foo" => "d", "bar" => 2}, + {"b" => "2", "a" => "1", "c" => "3"}, + {}, + {"name" => "value"}, + {"a" => true, "b" => false}, + # Absent, null, and empty string in every position. + {"a" => UNDEFINED}, + {"a" => nil}, + {"a" => ""}, + {"a" => UNDEFINED, "b" => "x"}, + {"a" => nil, "b" => "x"}, + {"a" => "", "b" => "x"}, + {"a" => {"b" => UNDEFINED}}, + {"a" => {"b" => nil}}, + {"a" => {"b" => ""}}, + {"a" => nil, "b" => UNDEFINED, "c" => ""}, + # Nesting, depth >= 3. + {"a" => {"b" => 1}}, + {"a" => {"b" => {"c" => 1}}}, + {"a" => {"b" => {"c" => {"d" => "deep"}}}}, + {"a" => {"b" => "x", "c" => {"d" => "y"}}, "e" => "z"}, + {"a" => {}}, + {"a" => {"b" => {}}}, + # Arrays. + {"ids" => []}, + {"ids" => ["a"]}, + {"ids" => ["a", "b"]}, + {"ids" => ["b", "a", "c"]}, + {"ids" => [1, 2, 3]}, + {"ids" => [true, false]}, + {"ids" => [FLOAT.call("1.5"), FLOAT.call("2.5")]}, + {"ids" => [DATE.call(1_700_000_000_123)]}, + {"nested" => {"ids" => ["x", "y"]}}, + # Array error cases. + {"ids" => [""]}, + {"ids" => ["a", ""]}, + {"ids" => ["", "a"]}, + {"ids" => ["a", nil]}, + {"ids" => [nil]}, + {"ids" => ["a", UNDEFINED]}, + # Key error cases. + {"a.b" => 1}, + {"a" => {"b.c" => 1}}, + {"." => 1}, + # Explicit null at top level and nested. + {"a" => nil, "b" => 1}, + {"a" => {"b" => nil, "c" => 2}}, + # Non-finite numbers. + {"n" => FLOAT.call("NaN")}, + {"n" => FLOAT.call("Infinity")}, + {"n" => FLOAT.call("-Infinity")}, + # Zeros. + {"n" => FLOAT.call("0.0")}, + {"n" => FLOAT.call("-0.0")}, + {"n" => 0}, + # Integral floats and integers. + {"n" => FLOAT.call("1.0")}, + {"n" => FLOAT.call("-1.0")}, + {"n" => FLOAT.call("100.0")}, + {"n" => 42}, + {"n" => -42}, + # Exponent boundaries. + {"n" => FLOAT.call("1e20")}, + {"n" => FLOAT.call("1e21")}, + {"n" => FLOAT.call("1e-6")}, + {"n" => FLOAT.call("1e-7")}, + {"n" => FLOAT.call("-1e20")}, + {"n" => FLOAT.call("-1e21")}, + {"n" => FLOAT.call("-1e-6")}, + {"n" => FLOAT.call("-1e-7")}, + {"n" => FLOAT.call("1.5e22")}, + {"n" => FLOAT.call("123456.789")}, + {"n" => FLOAT.call("0.30000000000000004")}, + {"n" => FLOAT.call("5e-324")}, + {"n" => FLOAT.call("1.7976931348623157e308")}, + # Large integers beyond float precision. + {"n" => BIGINT.call("9007199254740993")}, + {"n" => BIGINT.call("123456789123456789123456789")}, + {"n" => BIGINT.call("-123456789123456789123456789")}, + # Characters the encoder must handle. + {"c" => "a *~ b"}, + {"c" => "*"}, + {"c" => "~"}, + {"c" => "a+b"}, + {"c" => "a&b=c"}, + {"c" => "100%"}, + {"c" => "%20"}, + {"c" => "?query#fragment"}, + {"c" => "/slash\\backslash"}, + {"c" => "'quotes\""}, + {"c" => "!()"}, + {"c" => "a\nb\tc"}, + {"a b" => "c d"}, + {"a&b" => "c=d"}, + {"a+b" => "c+d"}, + {"a%b" => "c%d"}, + {"a*~b" => "c*~d"}, + # Multi-byte UTF-8 and astral plane, as both keys and values. + {"c" => "café"}, + {"c" => "日本語"}, + {"c" => "\u{1F600}"}, + {"\u{1F600}" => "emoji key"}, + {"café" => "ü"}, + # Sort: UTF-16 code unit order puts astral (surrogate) keys before + # U+E000..U+FFFF, and the sort is stable for repeated names. + {"\u{FFFF}" => "1", "\u{1F600}" => "2"}, + {"\u{E000}" => "1", "\u{1F600}" => "2", "z" => "3"}, + {"b" => ["2", "1"], "a" => "0", "c" => ["x", "y"]}, + {"Z" => "1", "a" => "2", "A" => "3", "z" => "4", "0" => "5"}, + {"10" => "a", "2" => "b", "1" => "c"}, + # Dates. + {"t" => DATE.call(0)}, + {"t" => DATE.call(1_700_000_000_000)}, + {"t" => DATE.call(1_700_000_000_123)}, + {"t" => DATE.call(-86_400_000)}, + {"t" => DATE.call(-1)}, + {"t" => DATE.call(253_402_300_799_999)}, + {"t" => DATE.call(946_684_800_000)}, + {"starts_at" => DATE.call(1_700_000_000_001), "ends_at" => DATE.call(1_700_003_600_999)}, + # Unsupported value types (arrays and hashes as array elements). + {"a" => [["nested"]]}, + {"a" => [{"b" => 1}]}, + # Mixed realistic params. + { + "device_ids" => %w[device-1 device-2], + "limit" => 50, + "custom_metadata_has" => {"internal_account_id" => "user-1"}, + "starts_at" => DATE.call(1_700_000_000_000), + "unstable_offset" => FLOAT.call("0.5"), + "is_managed" => true, + "search" => nil, + "page_cursor" => UNDEFINED + } +] + +cases.concat(hand_built) + +# --- Tier 2: randomized structural fuzz. --- + +KEY_ALPHABETS = [ + ("a".."z").to_a, + ("A".."Z").to_a + ("0".."9").to_a + ["_", "-"], + ["k", " ", "+", "&", "=", "%", "*", "~", "é", "日", "\u{1F600}", "\u{FFFF}", "\u{E000}"] +].freeze + +def random_key(random) + alphabet = KEY_ALPHABETS[random.rand(KEY_ALPHABETS.length)] + length = 1 + random.rand(8) + key = Array.new(length) { alphabet[random.rand(alphabet.length)] }.join + # "." is the nesting separator and "$" is the fixture tag prefix. + key = key.tr(".", "_") + key.start_with?("$") ? "_#{key}" : key +end + +def random_string(random) + pool = [ + "value", "a *~ b", "", "café", "日本語", "😀", "a&b=c", "+%20", "line\nbreak", + "x" * (1 + random.rand(20)) + ] + pool[random.rand(pool.length)] +end + +def random_scalar(random) + case random.rand(10) + when 0 then random_string(random) + when 1 then random.rand(2_000_000) - 1_000_000 + when 2 then FLOAT.call((random.rand * 10**(random.rand(41) - 20)).to_s) + when 3 then [true, false][random.rand(2)] + when 4 then DATE.call(random.rand(4_102_444_800_000) - 86_400_000) + when 5 then nil + when 6 then UNDEFINED + when 7 then BIGINT.call(random.rand(10**(random.rand(30) + 1)).to_s) + when 8 then random_string(random) + else random.rand(100) + end +end + +def random_array(random) + # Uniform element types keep the case parseable for the round-trip check. + case random.rand(6) + when 0 then [] + when 1 then Array.new(1 + random.rand(4)) { random_string(random).sub(/\A\z/, "s") } + when 2 then Array.new(1 + random.rand(4)) { random.rand(10_000) } + when 3 then Array.new(1 + random.rand(4)) { FLOAT.call((random.rand * 1000).to_s) } + when 4 then Array.new(1 + random.rand(4)) { DATE.call(random.rand(4_102_444_800_000)) } + else Array.new(1 + random.rand(3)) { random_scalar(random) } + end +end + +def random_params(random, depth) + params = {} + (1 + random.rand(6)).times do + key = random_key(random) + params[key] = case random.rand(10) + when 0, 1 then (depth < 3) ? random_params(random, depth + 1) : random_scalar(random) + when 2, 3 then random_array(random) + else random_scalar(random) + end + end + params +end + +3000.times { cases << random_params(random, 0) } + +# --- Tier 3: float fuzz. --- + +float_cases = [] + +# Every power of ten across the double range, from both sides of each +# exponent-notation boundary. +(-324..308).each do |exponent| + float_cases << "1e#{exponent}" + float_cases << "-1e#{exponent}" + float_cases << "9.99e#{exponent}" +end + +# Random doubles across many magnitudes. +25_000.times do + exponent = random.rand(640) - 324 + float_cases << (random.rand * 10.0**exponent).to_s +end + +# Random bit patterns cover subnormals and extreme mantissas. +5_000.times do + value = [random.rand(2**64)].pack("Q").unpack1("d") + float_cases << if value.nan? + "NaN" + elsif value.infinite? + (value > 0) ? "Infinity" : "-Infinity" + else + value.to_s + end +end + +float_cases.each { |repr| cases << {"n" => FLOAT.call(repr)} } + +File.write(fixture_path, JSON.generate(cases)) + +warn "Wrote #{cases.length} cases to #{fixture_path} (seed #{seed})" diff --git a/dev/conformance/roundtrip.mjs b/dev/conformance/roundtrip.mjs new file mode 100644 index 0000000..a92792e --- /dev/null +++ b/dev/conformance/roundtrip.mjs @@ -0,0 +1,161 @@ +// Feeds the Ruby serializer's output back through +// @seamapi/url-search-params-parser in strict mode and asserts the parsed +// structure matches the fixture input. Matching the reference byte for byte +// is the strong property; round-tripping proves both sides agree on what the +// string means. +// +// Cases are skipped when the parser cannot express them: error cases (the +// serializer rejected the input), boolean or mixed-type arrays, and integers +// beyond Number.MAX_SAFE_INTEGER. +// +// Usage: node roundtrip.mjs FIXTURE_PATH RUBY_OUTPUT_PATH + +import { readFileSync } from 'node:fs' + +import { parseUrlSearchParams } from '@seamapi/url-search-params-parser' +import { z } from 'zod' + +const SKIP = Symbol('skip') + +const revive = (value) => { + if (value === null) return null + if (Array.isArray(value)) return value.map(revive) + if (typeof value === 'object') { + if ('$float' in value) return Number(value.$float) + if ('$bigint' in value) return BigInt(value.$bigint) + if ('$date' in value) return new Date(value.$date) + if ('$undefined' in value) return undefined + return Object.fromEntries( + Object.entries(value).map(([k, v]) => [k, revive(v)]), + ) + } + return value +} + +// Returns { schema, expected } for a revived params object, or SKIP when the +// parser cannot express the case. +const planFor = (value) => { + if (value === null) { + return { schema: z.string().nullable(), expected: null } + } + if (typeof value === 'string') { + return value === '' ? SKIP : { schema: z.string(), expected: value } + } + if (typeof value === 'number') { + if (!Number.isFinite(value)) return SKIP + return { schema: z.number(), expected: Object.is(value, -0) ? 0 : value } + } + if (typeof value === 'bigint') { + if (value > BigInt(Number.MAX_SAFE_INTEGER)) return SKIP + if (value < -BigInt(Number.MAX_SAFE_INTEGER)) return SKIP + return { schema: z.number(), expected: Number(value) } + } + if (typeof value === 'boolean') { + return { schema: z.boolean(), expected: value } + } + if (value instanceof Date) { + return { schema: z.date(), expected: value } + } + if (Array.isArray(value)) { + if (value.length === 0) { + return { schema: z.array(z.string()), expected: [] } + } + const plans = value.map(planFor) + if (plans.some((p) => p === SKIP)) return SKIP + const first = value[0] + const uniform = (check) => value.every(check) + if (typeof first === 'string' && uniform((v) => typeof v === 'string')) { + return { schema: z.array(z.string()), expected: value } + } + if ( + (typeof first === 'number' || typeof first === 'bigint') && + uniform((v) => typeof v === 'number' || typeof v === 'bigint') + ) { + return { + schema: z.array(z.number()), + expected: plans.map((p) => p.expected), + } + } + if (first instanceof Date && uniform((v) => v instanceof Date)) { + return { schema: z.array(z.date()), expected: value } + } + // The parser has no boolean or mixed-type array schemas. + return SKIP + } + if (typeof value === 'object') { + const shape = {} + const expected = {} + for (const [key, element] of Object.entries(value)) { + if (element === undefined) continue + const plan = planFor(element) + if (plan === SKIP) return SKIP + shape[key] = plan.schema + expected[key] = plan.expected + } + return { schema: z.object(shape), expected } + } + return SKIP +} + +const equal = (actual, expected) => { + if (expected instanceof Date) { + return actual instanceof Date && actual.getTime() === expected.getTime() + } + if (Array.isArray(expected)) { + return ( + Array.isArray(actual) && + actual.length === expected.length && + expected.every((element, i) => equal(actual[i], element)) + ) + } + if (expected !== null && typeof expected === 'object') { + if (actual === null || typeof actual !== 'object') return false + const actualKeys = Object.keys(actual) + const expectedKeys = Object.keys(expected) + if (actualKeys.length !== expectedKeys.length) return false + return expectedKeys.every((key) => equal(actual[key], expected[key])) + } + return Object.is(actual, expected) +} + +const cases = JSON.parse(readFileSync(process.argv[2], 'utf8')) +const lines = readFileSync(process.argv[3], 'utf8').split('\n') + +let checked = 0 +let skipped = 0 +let failures = 0 + +cases.forEach((params, i) => { + const line = lines[i] + if (line === '!ERROR') { + skipped += 1 + return + } + const plan = planFor(revive(params)) + if (plan === SKIP) { + skipped += 1 + return + } + let parsed + try { + parsed = parseUrlSearchParams(line, plan.schema, { strict: true }) + } catch (err) { + failures += 1 + console.error(`case ${i}: parser rejected ${JSON.stringify(line)}: ${err}`) + return + } + if (equal(parsed, plan.expected)) { + checked += 1 + } else { + failures += 1 + console.error( + `case ${i}: mismatch for ${JSON.stringify(line)}\n parsed: ${JSON.stringify(parsed)}\n expected: ${JSON.stringify(plan.expected)}`, + ) + } +}) + +console.log( + `Round trip: ${checked} matched, ${skipped} skipped (errors or unparseable types), ${failures} failed of ${cases.length} cases`, +) + +if (failures > 0) process.exit(1) diff --git a/dev/conformance/run b/dev/conformance/run new file mode 100755 index 0000000..4180fa7 --- /dev/null +++ b/dev/conformance/run @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Conformance harness for the Seam URL search params serializer. +# +# Generates a fresh fixture, runs the TypeScript reference implementation and +# the Ruby port over it, and diffs the outputs byte for byte, then feeds the +# Ruby output back through @seamapi/url-search-params-parser in strict mode. +# +# This harness needs Node and the reference implementation (run npm install +# first), which gem consumers should not, so it lives outside the shipped gem +# and runs in CI rather than in the RSpec suite. +# +# Usage: dev/conformance/run [SEED] + +set -euo pipefail + +cd "$(dirname "$0")" + +tmp="$(pwd)/tmp" +mkdir -p "$tmp" + +fixture="$tmp/fixture.json" +marker="$tmp/marker" + +# The fixture must be written by this run: a stale fixture from an earlier +# run could make the comparison pass vacuously. +touch "$marker" +sleep 0.01 +ruby generate_fixture.rb "$fixture" "${1:-}" + +if [ ! "$fixture" -nt "$marker" ]; then + echo "FAIL: fixture $fixture is stale (not newer than this run)" >&2 + exit 1 +fi + +node run_reference.mjs "$fixture" > "$tmp/reference.txt" +ruby run_ruby.rb "$fixture" > "$tmp/ruby.txt" + +if ! cmp "$tmp/reference.txt" "$tmp/ruby.txt"; then + echo "FAIL: outputs differ; first differences:" >&2 + diff "$tmp/reference.txt" "$tmp/ruby.txt" | head -40 >&2 + exit 1 +fi + +count="$(wc -l < "$tmp/ruby.txt")" +echo "Conformance: all $count cases byte-for-byte identical to the reference" + +node roundtrip.mjs "$fixture" "$tmp/ruby.txt" diff --git a/dev/conformance/run_reference.mjs b/dev/conformance/run_reference.mjs new file mode 100644 index 0000000..1208a86 --- /dev/null +++ b/dev/conformance/run_reference.mjs @@ -0,0 +1,41 @@ +// Runs the TypeScript reference implementation over the conformance fixture, +// printing one line per case: the serialized query string, or !ERROR when the +// case throws UnserializableParamError. Compare byte for byte against the +// output of run_ruby.rb. +// +// Usage: node run_reference.mjs FIXTURE_PATH + +import { readFileSync } from 'node:fs' + +import { + serializeUrlSearchParams, + UnserializableParamError, +} from '@seamapi/url-search-params-serializer' + +const revive = (value) => { + if (value === null) return null + if (Array.isArray(value)) return value.map(revive) + if (typeof value === 'object') { + if ('$float' in value) return Number(value.$float) + if ('$bigint' in value) return BigInt(value.$bigint) + if ('$date' in value) return new Date(value.$date) + if ('$undefined' in value) return undefined + return Object.fromEntries( + Object.entries(value).map(([k, v]) => [k, revive(v)]), + ) + } + return value +} + +const cases = JSON.parse(readFileSync(process.argv[2], 'utf8')) + +const lines = cases.map((params) => { + try { + return serializeUrlSearchParams(revive(params)) + } catch (err) { + if (err instanceof UnserializableParamError) return '!ERROR' + throw err + } +}) + +process.stdout.write(lines.join('\n') + '\n') diff --git a/dev/conformance/run_ruby.rb b/dev/conformance/run_ruby.rb new file mode 100644 index 0000000..22da32f --- /dev/null +++ b/dev/conformance/run_ruby.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +# Runs the Ruby serializer over the conformance fixture, printing one line per +# case: the serialized query string, or !ERROR when the case raises +# Seam::UnserializableParamError. Compare byte for byte against the output of +# run_reference.mjs. +# +# Usage: ruby run_ruby.rb FIXTURE_PATH + +require "json" + +$LOAD_PATH.unshift File.expand_path("../../lib", __dir__) +require "seam/url_search_params_serializer" + +def revive(value) + case value + when nil + Seam::NULL + when Array + value.map { |element| revive(element) } + when Hash + return revive_float(value.fetch("$float")) if value.key?("$float") + return Integer(value.fetch("$bigint")) if value.key?("$bigint") + return revive_date(value.fetch("$date")) if value.key?("$date") + return nil if value.key?("$undefined") + + value.transform_values { |element| revive(element) } + else + value + end +end + +def revive_float(repr) + case repr + when "NaN" then Float::NAN + when "Infinity" then Float::INFINITY + when "-Infinity" then -Float::INFINITY + else Float(repr) + end +end + +def revive_date(epoch_milliseconds) + Time.at( + epoch_milliseconds / 1000, + (epoch_milliseconds % 1000) * 1000, + :usec, + in: "UTC" + ) +end + +cases = JSON.parse(File.read(ARGV.fetch(0), encoding: Encoding::UTF_8)) + +output = cases.map do |params| + Seam.serialize_url_search_params(revive(params)) +rescue Seam::UnserializableParamError + "!ERROR" +end + +puts output diff --git a/package-lock.json b/package-lock.json index d8d7f8b..e4e2c62 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,8 @@ "@seamapi/fake-seam-connect": "2.0.4", "@seamapi/smith": "^1.1.0", "@seamapi/types": "1.1001.0", + "@seamapi/url-search-params-parser": "^0.2.4", + "@seamapi/url-search-params-serializer": "^3.0.3", "change-case": "^5.4.4", "markdown-toc": "^1.2.0", "prettier": "^3.2.5" @@ -867,6 +869,31 @@ "zod": "^3.24.0" } }, + "node_modules/@seamapi/url-search-params-parser": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@seamapi/url-search-params-parser/-/url-search-params-parser-0.2.4.tgz", + "integrity": "sha512-RppzxKs/JM2dd7gRdwb6Epo8w1pi/lp2T77iScbpGqij/8Qz51rVDQr6USBtU/16bptJjrxracB7qR9AdZfZEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.12.0", + "npm": ">=10.0.0" + }, + "peerDependencies": { + "zod": "^3.0.0 || ^4.0.0" + } + }, + "node_modules/@seamapi/url-search-params-serializer": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@seamapi/url-search-params-serializer/-/url-search-params-serializer-3.0.3.tgz", + "integrity": "sha512-orquLaJ0mtrZvKRpobkXza0vRDqzkKcOszlswkWYYQKNpdolXE3+msD63757s6jodeypbHXpMlC4UZQe8WmPlQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.12.0", + "npm": ">=10.0.0" + } + }, "node_modules/@sindresorhus/merge-streams": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz", diff --git a/package.json b/package.json index a2d9662..f4e2a3c 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,8 @@ "@seamapi/fake-seam-connect": "2.0.4", "@seamapi/smith": "^1.1.0", "@seamapi/types": "1.1001.0", + "@seamapi/url-search-params-parser": "^0.2.4", + "@seamapi/url-search-params-serializer": "^3.0.3", "change-case": "^5.4.4", "markdown-toc": "^1.2.0", "prettier": "^3.2.5" From ead020b9fcac2a1acd590546a057ac0436ee3e47 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 19:28:14 +0000 Subject: [PATCH 4/9] 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 Claude-Session: https://claude.ai/code/session_017p9TwdqYv9ccnv7nGvpfsp --- README.md | 17 -- dev/conformance/generate_fixture.rb | 278 ---------------------- dev/conformance/roundtrip.mjs | 161 ------------- dev/conformance/run | 47 ---- dev/conformance/run_reference.mjs | 41 ---- dev/conformance/run_ruby.rb | 59 ----- package-lock.json | 27 --- package.json | 2 - spec/url_search_params_serializer_spec.rb | 10 + 9 files changed, 10 insertions(+), 632 deletions(-) delete mode 100644 dev/conformance/generate_fixture.rb delete mode 100644 dev/conformance/roundtrip.mjs delete mode 100755 dev/conformance/run delete mode 100644 dev/conformance/run_reference.mjs delete mode 100644 dev/conformance/run_ruby.rb diff --git a/README.md b/README.md index 70a80e1..9ef197b 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,6 @@ accurate and fully typed. - [URL search params serialization](#url-search-params-serialization) - [Development and Testing](#development-and-testing) - [Quickstart](#quickstart) - - [URL search params conformance](#url-search-params-conformance) - [Source code](#source-code) - [Requirements](#requirements) - [Publishing](#publishing) @@ -586,22 +585,6 @@ $ bundle exec rake -T [rake]: https://ruby.github.io/rake/ -### URL search params conformance - -The URL search params serializer is verified byte for byte against the -TypeScript reference implementation over a generated fixture of hand-built, -structural-fuzz, and float-fuzz cases, and round-tripped through -[@seamapi/url-search-params-parser][parser] in strict mode. -The harness needs Node and the reference implementation, -which gem consumers should not, -so it lives in `dev/conformance` outside the shipped gem. -Run it with - -``` -$ npm install -$ dev/conformance/run -``` - ### Source code The [source code] is hosted on GitHub. diff --git a/dev/conformance/generate_fixture.rb b/dev/conformance/generate_fixture.rb deleted file mode 100644 index b4e2d01..0000000 --- a/dev/conformance/generate_fixture.rb +++ /dev/null @@ -1,278 +0,0 @@ -# frozen_string_literal: true - -# Generates the shared conformance fixture read by both the TypeScript -# reference implementation (run_reference.mjs) and the Ruby port (run_ruby.rb). -# -# Values that JSON cannot express directly use tagged objects so a date does -# not round-trip through the fixture as a string: -# -# {"$float" => "1.5"} -> Float / JavaScript number (also "NaN", -# "Infinity", and "-Infinity") -# {"$bigint" => "123"} -> Integer / JavaScript BigInt -# {"$date" => 1700000000123} -> Time / JavaScript Date (integer epoch -# milliseconds; JavaScript Dates cannot carry -# sub-millisecond precision, so truncation is -# covered by a Ruby-only unit test instead) -# {"$undefined" => true} -> nil / JavaScript undefined -# JSON null -> Seam::NULL / JavaScript null -# -# Keys never start with "$" except inside tag objects. -# -# Usage: ruby generate_fixture.rb FIXTURE_PATH [SEED] - -require "json" - -fixture_path = ARGV.fetch(0) -seed = (ARGV[1] || Random.new_seed).to_i -random = Random.new(seed) - -FLOAT = ->(value) { {"$float" => value.to_s} } -BIGINT = ->(value) { {"$bigint" => value.to_s} } -DATE = ->(ms) { {"$date" => ms} } -UNDEFINED = {"$undefined" => true}.freeze - -cases = [] - -# --- Tier 1: hand-built cases covering every branch of the standard. --- - -hand_built = [ - # Basic scalars and sorting. - {"foo" => "d", "bar" => 2}, - {"b" => "2", "a" => "1", "c" => "3"}, - {}, - {"name" => "value"}, - {"a" => true, "b" => false}, - # Absent, null, and empty string in every position. - {"a" => UNDEFINED}, - {"a" => nil}, - {"a" => ""}, - {"a" => UNDEFINED, "b" => "x"}, - {"a" => nil, "b" => "x"}, - {"a" => "", "b" => "x"}, - {"a" => {"b" => UNDEFINED}}, - {"a" => {"b" => nil}}, - {"a" => {"b" => ""}}, - {"a" => nil, "b" => UNDEFINED, "c" => ""}, - # Nesting, depth >= 3. - {"a" => {"b" => 1}}, - {"a" => {"b" => {"c" => 1}}}, - {"a" => {"b" => {"c" => {"d" => "deep"}}}}, - {"a" => {"b" => "x", "c" => {"d" => "y"}}, "e" => "z"}, - {"a" => {}}, - {"a" => {"b" => {}}}, - # Arrays. - {"ids" => []}, - {"ids" => ["a"]}, - {"ids" => ["a", "b"]}, - {"ids" => ["b", "a", "c"]}, - {"ids" => [1, 2, 3]}, - {"ids" => [true, false]}, - {"ids" => [FLOAT.call("1.5"), FLOAT.call("2.5")]}, - {"ids" => [DATE.call(1_700_000_000_123)]}, - {"nested" => {"ids" => ["x", "y"]}}, - # Array error cases. - {"ids" => [""]}, - {"ids" => ["a", ""]}, - {"ids" => ["", "a"]}, - {"ids" => ["a", nil]}, - {"ids" => [nil]}, - {"ids" => ["a", UNDEFINED]}, - # Key error cases. - {"a.b" => 1}, - {"a" => {"b.c" => 1}}, - {"." => 1}, - # Explicit null at top level and nested. - {"a" => nil, "b" => 1}, - {"a" => {"b" => nil, "c" => 2}}, - # Non-finite numbers. - {"n" => FLOAT.call("NaN")}, - {"n" => FLOAT.call("Infinity")}, - {"n" => FLOAT.call("-Infinity")}, - # Zeros. - {"n" => FLOAT.call("0.0")}, - {"n" => FLOAT.call("-0.0")}, - {"n" => 0}, - # Integral floats and integers. - {"n" => FLOAT.call("1.0")}, - {"n" => FLOAT.call("-1.0")}, - {"n" => FLOAT.call("100.0")}, - {"n" => 42}, - {"n" => -42}, - # Exponent boundaries. - {"n" => FLOAT.call("1e20")}, - {"n" => FLOAT.call("1e21")}, - {"n" => FLOAT.call("1e-6")}, - {"n" => FLOAT.call("1e-7")}, - {"n" => FLOAT.call("-1e20")}, - {"n" => FLOAT.call("-1e21")}, - {"n" => FLOAT.call("-1e-6")}, - {"n" => FLOAT.call("-1e-7")}, - {"n" => FLOAT.call("1.5e22")}, - {"n" => FLOAT.call("123456.789")}, - {"n" => FLOAT.call("0.30000000000000004")}, - {"n" => FLOAT.call("5e-324")}, - {"n" => FLOAT.call("1.7976931348623157e308")}, - # Large integers beyond float precision. - {"n" => BIGINT.call("9007199254740993")}, - {"n" => BIGINT.call("123456789123456789123456789")}, - {"n" => BIGINT.call("-123456789123456789123456789")}, - # Characters the encoder must handle. - {"c" => "a *~ b"}, - {"c" => "*"}, - {"c" => "~"}, - {"c" => "a+b"}, - {"c" => "a&b=c"}, - {"c" => "100%"}, - {"c" => "%20"}, - {"c" => "?query#fragment"}, - {"c" => "/slash\\backslash"}, - {"c" => "'quotes\""}, - {"c" => "!()"}, - {"c" => "a\nb\tc"}, - {"a b" => "c d"}, - {"a&b" => "c=d"}, - {"a+b" => "c+d"}, - {"a%b" => "c%d"}, - {"a*~b" => "c*~d"}, - # Multi-byte UTF-8 and astral plane, as both keys and values. - {"c" => "café"}, - {"c" => "日本語"}, - {"c" => "\u{1F600}"}, - {"\u{1F600}" => "emoji key"}, - {"café" => "ü"}, - # Sort: UTF-16 code unit order puts astral (surrogate) keys before - # U+E000..U+FFFF, and the sort is stable for repeated names. - {"\u{FFFF}" => "1", "\u{1F600}" => "2"}, - {"\u{E000}" => "1", "\u{1F600}" => "2", "z" => "3"}, - {"b" => ["2", "1"], "a" => "0", "c" => ["x", "y"]}, - {"Z" => "1", "a" => "2", "A" => "3", "z" => "4", "0" => "5"}, - {"10" => "a", "2" => "b", "1" => "c"}, - # Dates. - {"t" => DATE.call(0)}, - {"t" => DATE.call(1_700_000_000_000)}, - {"t" => DATE.call(1_700_000_000_123)}, - {"t" => DATE.call(-86_400_000)}, - {"t" => DATE.call(-1)}, - {"t" => DATE.call(253_402_300_799_999)}, - {"t" => DATE.call(946_684_800_000)}, - {"starts_at" => DATE.call(1_700_000_000_001), "ends_at" => DATE.call(1_700_003_600_999)}, - # Unsupported value types (arrays and hashes as array elements). - {"a" => [["nested"]]}, - {"a" => [{"b" => 1}]}, - # Mixed realistic params. - { - "device_ids" => %w[device-1 device-2], - "limit" => 50, - "custom_metadata_has" => {"internal_account_id" => "user-1"}, - "starts_at" => DATE.call(1_700_000_000_000), - "unstable_offset" => FLOAT.call("0.5"), - "is_managed" => true, - "search" => nil, - "page_cursor" => UNDEFINED - } -] - -cases.concat(hand_built) - -# --- Tier 2: randomized structural fuzz. --- - -KEY_ALPHABETS = [ - ("a".."z").to_a, - ("A".."Z").to_a + ("0".."9").to_a + ["_", "-"], - ["k", " ", "+", "&", "=", "%", "*", "~", "é", "日", "\u{1F600}", "\u{FFFF}", "\u{E000}"] -].freeze - -def random_key(random) - alphabet = KEY_ALPHABETS[random.rand(KEY_ALPHABETS.length)] - length = 1 + random.rand(8) - key = Array.new(length) { alphabet[random.rand(alphabet.length)] }.join - # "." is the nesting separator and "$" is the fixture tag prefix. - key = key.tr(".", "_") - key.start_with?("$") ? "_#{key}" : key -end - -def random_string(random) - pool = [ - "value", "a *~ b", "", "café", "日本語", "😀", "a&b=c", "+%20", "line\nbreak", - "x" * (1 + random.rand(20)) - ] - pool[random.rand(pool.length)] -end - -def random_scalar(random) - case random.rand(10) - when 0 then random_string(random) - when 1 then random.rand(2_000_000) - 1_000_000 - when 2 then FLOAT.call((random.rand * 10**(random.rand(41) - 20)).to_s) - when 3 then [true, false][random.rand(2)] - when 4 then DATE.call(random.rand(4_102_444_800_000) - 86_400_000) - when 5 then nil - when 6 then UNDEFINED - when 7 then BIGINT.call(random.rand(10**(random.rand(30) + 1)).to_s) - when 8 then random_string(random) - else random.rand(100) - end -end - -def random_array(random) - # Uniform element types keep the case parseable for the round-trip check. - case random.rand(6) - when 0 then [] - when 1 then Array.new(1 + random.rand(4)) { random_string(random).sub(/\A\z/, "s") } - when 2 then Array.new(1 + random.rand(4)) { random.rand(10_000) } - when 3 then Array.new(1 + random.rand(4)) { FLOAT.call((random.rand * 1000).to_s) } - when 4 then Array.new(1 + random.rand(4)) { DATE.call(random.rand(4_102_444_800_000)) } - else Array.new(1 + random.rand(3)) { random_scalar(random) } - end -end - -def random_params(random, depth) - params = {} - (1 + random.rand(6)).times do - key = random_key(random) - params[key] = case random.rand(10) - when 0, 1 then (depth < 3) ? random_params(random, depth + 1) : random_scalar(random) - when 2, 3 then random_array(random) - else random_scalar(random) - end - end - params -end - -3000.times { cases << random_params(random, 0) } - -# --- Tier 3: float fuzz. --- - -float_cases = [] - -# Every power of ten across the double range, from both sides of each -# exponent-notation boundary. -(-324..308).each do |exponent| - float_cases << "1e#{exponent}" - float_cases << "-1e#{exponent}" - float_cases << "9.99e#{exponent}" -end - -# Random doubles across many magnitudes. -25_000.times do - exponent = random.rand(640) - 324 - float_cases << (random.rand * 10.0**exponent).to_s -end - -# Random bit patterns cover subnormals and extreme mantissas. -5_000.times do - value = [random.rand(2**64)].pack("Q").unpack1("d") - float_cases << if value.nan? - "NaN" - elsif value.infinite? - (value > 0) ? "Infinity" : "-Infinity" - else - value.to_s - end -end - -float_cases.each { |repr| cases << {"n" => FLOAT.call(repr)} } - -File.write(fixture_path, JSON.generate(cases)) - -warn "Wrote #{cases.length} cases to #{fixture_path} (seed #{seed})" diff --git a/dev/conformance/roundtrip.mjs b/dev/conformance/roundtrip.mjs deleted file mode 100644 index a92792e..0000000 --- a/dev/conformance/roundtrip.mjs +++ /dev/null @@ -1,161 +0,0 @@ -// Feeds the Ruby serializer's output back through -// @seamapi/url-search-params-parser in strict mode and asserts the parsed -// structure matches the fixture input. Matching the reference byte for byte -// is the strong property; round-tripping proves both sides agree on what the -// string means. -// -// Cases are skipped when the parser cannot express them: error cases (the -// serializer rejected the input), boolean or mixed-type arrays, and integers -// beyond Number.MAX_SAFE_INTEGER. -// -// Usage: node roundtrip.mjs FIXTURE_PATH RUBY_OUTPUT_PATH - -import { readFileSync } from 'node:fs' - -import { parseUrlSearchParams } from '@seamapi/url-search-params-parser' -import { z } from 'zod' - -const SKIP = Symbol('skip') - -const revive = (value) => { - if (value === null) return null - if (Array.isArray(value)) return value.map(revive) - if (typeof value === 'object') { - if ('$float' in value) return Number(value.$float) - if ('$bigint' in value) return BigInt(value.$bigint) - if ('$date' in value) return new Date(value.$date) - if ('$undefined' in value) return undefined - return Object.fromEntries( - Object.entries(value).map(([k, v]) => [k, revive(v)]), - ) - } - return value -} - -// Returns { schema, expected } for a revived params object, or SKIP when the -// parser cannot express the case. -const planFor = (value) => { - if (value === null) { - return { schema: z.string().nullable(), expected: null } - } - if (typeof value === 'string') { - return value === '' ? SKIP : { schema: z.string(), expected: value } - } - if (typeof value === 'number') { - if (!Number.isFinite(value)) return SKIP - return { schema: z.number(), expected: Object.is(value, -0) ? 0 : value } - } - if (typeof value === 'bigint') { - if (value > BigInt(Number.MAX_SAFE_INTEGER)) return SKIP - if (value < -BigInt(Number.MAX_SAFE_INTEGER)) return SKIP - return { schema: z.number(), expected: Number(value) } - } - if (typeof value === 'boolean') { - return { schema: z.boolean(), expected: value } - } - if (value instanceof Date) { - return { schema: z.date(), expected: value } - } - if (Array.isArray(value)) { - if (value.length === 0) { - return { schema: z.array(z.string()), expected: [] } - } - const plans = value.map(planFor) - if (plans.some((p) => p === SKIP)) return SKIP - const first = value[0] - const uniform = (check) => value.every(check) - if (typeof first === 'string' && uniform((v) => typeof v === 'string')) { - return { schema: z.array(z.string()), expected: value } - } - if ( - (typeof first === 'number' || typeof first === 'bigint') && - uniform((v) => typeof v === 'number' || typeof v === 'bigint') - ) { - return { - schema: z.array(z.number()), - expected: plans.map((p) => p.expected), - } - } - if (first instanceof Date && uniform((v) => v instanceof Date)) { - return { schema: z.array(z.date()), expected: value } - } - // The parser has no boolean or mixed-type array schemas. - return SKIP - } - if (typeof value === 'object') { - const shape = {} - const expected = {} - for (const [key, element] of Object.entries(value)) { - if (element === undefined) continue - const plan = planFor(element) - if (plan === SKIP) return SKIP - shape[key] = plan.schema - expected[key] = plan.expected - } - return { schema: z.object(shape), expected } - } - return SKIP -} - -const equal = (actual, expected) => { - if (expected instanceof Date) { - return actual instanceof Date && actual.getTime() === expected.getTime() - } - if (Array.isArray(expected)) { - return ( - Array.isArray(actual) && - actual.length === expected.length && - expected.every((element, i) => equal(actual[i], element)) - ) - } - if (expected !== null && typeof expected === 'object') { - if (actual === null || typeof actual !== 'object') return false - const actualKeys = Object.keys(actual) - const expectedKeys = Object.keys(expected) - if (actualKeys.length !== expectedKeys.length) return false - return expectedKeys.every((key) => equal(actual[key], expected[key])) - } - return Object.is(actual, expected) -} - -const cases = JSON.parse(readFileSync(process.argv[2], 'utf8')) -const lines = readFileSync(process.argv[3], 'utf8').split('\n') - -let checked = 0 -let skipped = 0 -let failures = 0 - -cases.forEach((params, i) => { - const line = lines[i] - if (line === '!ERROR') { - skipped += 1 - return - } - const plan = planFor(revive(params)) - if (plan === SKIP) { - skipped += 1 - return - } - let parsed - try { - parsed = parseUrlSearchParams(line, plan.schema, { strict: true }) - } catch (err) { - failures += 1 - console.error(`case ${i}: parser rejected ${JSON.stringify(line)}: ${err}`) - return - } - if (equal(parsed, plan.expected)) { - checked += 1 - } else { - failures += 1 - console.error( - `case ${i}: mismatch for ${JSON.stringify(line)}\n parsed: ${JSON.stringify(parsed)}\n expected: ${JSON.stringify(plan.expected)}`, - ) - } -}) - -console.log( - `Round trip: ${checked} matched, ${skipped} skipped (errors or unparseable types), ${failures} failed of ${cases.length} cases`, -) - -if (failures > 0) process.exit(1) diff --git a/dev/conformance/run b/dev/conformance/run deleted file mode 100755 index 4180fa7..0000000 --- a/dev/conformance/run +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env bash -# Conformance harness for the Seam URL search params serializer. -# -# Generates a fresh fixture, runs the TypeScript reference implementation and -# the Ruby port over it, and diffs the outputs byte for byte, then feeds the -# Ruby output back through @seamapi/url-search-params-parser in strict mode. -# -# This harness needs Node and the reference implementation (run npm install -# first), which gem consumers should not, so it lives outside the shipped gem -# and runs in CI rather than in the RSpec suite. -# -# Usage: dev/conformance/run [SEED] - -set -euo pipefail - -cd "$(dirname "$0")" - -tmp="$(pwd)/tmp" -mkdir -p "$tmp" - -fixture="$tmp/fixture.json" -marker="$tmp/marker" - -# The fixture must be written by this run: a stale fixture from an earlier -# run could make the comparison pass vacuously. -touch "$marker" -sleep 0.01 -ruby generate_fixture.rb "$fixture" "${1:-}" - -if [ ! "$fixture" -nt "$marker" ]; then - echo "FAIL: fixture $fixture is stale (not newer than this run)" >&2 - exit 1 -fi - -node run_reference.mjs "$fixture" > "$tmp/reference.txt" -ruby run_ruby.rb "$fixture" > "$tmp/ruby.txt" - -if ! cmp "$tmp/reference.txt" "$tmp/ruby.txt"; then - echo "FAIL: outputs differ; first differences:" >&2 - diff "$tmp/reference.txt" "$tmp/ruby.txt" | head -40 >&2 - exit 1 -fi - -count="$(wc -l < "$tmp/ruby.txt")" -echo "Conformance: all $count cases byte-for-byte identical to the reference" - -node roundtrip.mjs "$fixture" "$tmp/ruby.txt" diff --git a/dev/conformance/run_reference.mjs b/dev/conformance/run_reference.mjs deleted file mode 100644 index 1208a86..0000000 --- a/dev/conformance/run_reference.mjs +++ /dev/null @@ -1,41 +0,0 @@ -// Runs the TypeScript reference implementation over the conformance fixture, -// printing one line per case: the serialized query string, or !ERROR when the -// case throws UnserializableParamError. Compare byte for byte against the -// output of run_ruby.rb. -// -// Usage: node run_reference.mjs FIXTURE_PATH - -import { readFileSync } from 'node:fs' - -import { - serializeUrlSearchParams, - UnserializableParamError, -} from '@seamapi/url-search-params-serializer' - -const revive = (value) => { - if (value === null) return null - if (Array.isArray(value)) return value.map(revive) - if (typeof value === 'object') { - if ('$float' in value) return Number(value.$float) - if ('$bigint' in value) return BigInt(value.$bigint) - if ('$date' in value) return new Date(value.$date) - if ('$undefined' in value) return undefined - return Object.fromEntries( - Object.entries(value).map(([k, v]) => [k, revive(v)]), - ) - } - return value -} - -const cases = JSON.parse(readFileSync(process.argv[2], 'utf8')) - -const lines = cases.map((params) => { - try { - return serializeUrlSearchParams(revive(params)) - } catch (err) { - if (err instanceof UnserializableParamError) return '!ERROR' - throw err - } -}) - -process.stdout.write(lines.join('\n') + '\n') diff --git a/dev/conformance/run_ruby.rb b/dev/conformance/run_ruby.rb deleted file mode 100644 index 22da32f..0000000 --- a/dev/conformance/run_ruby.rb +++ /dev/null @@ -1,59 +0,0 @@ -# frozen_string_literal: true - -# Runs the Ruby serializer over the conformance fixture, printing one line per -# case: the serialized query string, or !ERROR when the case raises -# Seam::UnserializableParamError. Compare byte for byte against the output of -# run_reference.mjs. -# -# Usage: ruby run_ruby.rb FIXTURE_PATH - -require "json" - -$LOAD_PATH.unshift File.expand_path("../../lib", __dir__) -require "seam/url_search_params_serializer" - -def revive(value) - case value - when nil - Seam::NULL - when Array - value.map { |element| revive(element) } - when Hash - return revive_float(value.fetch("$float")) if value.key?("$float") - return Integer(value.fetch("$bigint")) if value.key?("$bigint") - return revive_date(value.fetch("$date")) if value.key?("$date") - return nil if value.key?("$undefined") - - value.transform_values { |element| revive(element) } - else - value - end -end - -def revive_float(repr) - case repr - when "NaN" then Float::NAN - when "Infinity" then Float::INFINITY - when "-Infinity" then -Float::INFINITY - else Float(repr) - end -end - -def revive_date(epoch_milliseconds) - Time.at( - epoch_milliseconds / 1000, - (epoch_milliseconds % 1000) * 1000, - :usec, - in: "UTC" - ) -end - -cases = JSON.parse(File.read(ARGV.fetch(0), encoding: Encoding::UTF_8)) - -output = cases.map do |params| - Seam.serialize_url_search_params(revive(params)) -rescue Seam::UnserializableParamError - "!ERROR" -end - -puts output diff --git a/package-lock.json b/package-lock.json index e4e2c62..d8d7f8b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,8 +10,6 @@ "@seamapi/fake-seam-connect": "2.0.4", "@seamapi/smith": "^1.1.0", "@seamapi/types": "1.1001.0", - "@seamapi/url-search-params-parser": "^0.2.4", - "@seamapi/url-search-params-serializer": "^3.0.3", "change-case": "^5.4.4", "markdown-toc": "^1.2.0", "prettier": "^3.2.5" @@ -869,31 +867,6 @@ "zod": "^3.24.0" } }, - "node_modules/@seamapi/url-search-params-parser": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/@seamapi/url-search-params-parser/-/url-search-params-parser-0.2.4.tgz", - "integrity": "sha512-RppzxKs/JM2dd7gRdwb6Epo8w1pi/lp2T77iScbpGqij/8Qz51rVDQr6USBtU/16bptJjrxracB7qR9AdZfZEg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=22.12.0", - "npm": ">=10.0.0" - }, - "peerDependencies": { - "zod": "^3.0.0 || ^4.0.0" - } - }, - "node_modules/@seamapi/url-search-params-serializer": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@seamapi/url-search-params-serializer/-/url-search-params-serializer-3.0.3.tgz", - "integrity": "sha512-orquLaJ0mtrZvKRpobkXza0vRDqzkKcOszlswkWYYQKNpdolXE3+msD63757s6jodeypbHXpMlC4UZQe8WmPlQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=22.12.0", - "npm": ">=10.0.0" - } - }, "node_modules/@sindresorhus/merge-streams": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz", diff --git a/package.json b/package.json index f4e2a3c..a2d9662 100644 --- a/package.json +++ b/package.json @@ -32,8 +32,6 @@ "@seamapi/fake-seam-connect": "2.0.4", "@seamapi/smith": "^1.1.0", "@seamapi/types": "1.1001.0", - "@seamapi/url-search-params-parser": "^0.2.4", - "@seamapi/url-search-params-serializer": "^3.0.3", "change-case": "^5.4.4", "markdown-toc": "^1.2.0", "prettier": "^3.2.5" diff --git a/spec/url_search_params_serializer_spec.rb b/spec/url_search_params_serializer_spec.rb index aee74bd..849f666 100644 --- a/spec/url_search_params_serializer_spec.rb +++ b/spec/url_search_params_serializer_spec.rb @@ -93,6 +93,10 @@ def serialize(params) expect(serialize({a: true, b: false})).to eq("a=true&b=false") end + it "encodes multi-byte and astral characters in keys and values" do + expect(serialize({"\u{1F600}" => "café"})).to eq("%F0%9F%98%80=caf%C3%A9") + end + describe "numbers" do it "serializes integers with full decimal digits at arbitrary precision" do expect(serialize({n: 123456789123456789123456789})).to eq("n=123456789123456789123456789") @@ -124,6 +128,12 @@ def serialize(params) expect(serialize({n: 123.456})).to eq("n=123.456") end + it "formats exponents like ECMAScript, signed and without zero padding" do + expect(serialize({n: 1.5e22})).to eq("n=1.5e%2B22") + expect(serialize({n: 5e-324})).to eq("n=5e-324") + expect(serialize({n: 1.7976931348623157e308})).to eq("n=1.7976931348623157e%2B308") + end + it "rejects NaN and infinities with their own messages" do expect { serialize({n: Float::NAN}) }.to raise_error( Seam::UnserializableParamError, "Could not serialize parameter: 'n' is NaN" From 8a60b6d970ecd45644a20379c3aac0987cdce915 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 04:26:31 +0000 Subject: [PATCH 5/9] 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 Claude-Session: https://claude.ai/code/session_017p9TwdqYv9ccnv7nGvpfsp --- README.md | 9 +- lib/seam.rb | 2 +- lib/seam/request.rb | 7 +- .../strict_url_search_params_serializer.rb | 20 +++++ lib/seam/url_search_params_serializer.rb | 87 +++++++++++-------- spec/seam_client/headers_spec.rb | 4 +- spec/seam_client/url_search_params_spec.rb | 24 ++--- spec/url_search_params_serializer_spec.rb | 37 +++++++- 8 files changed, 134 insertions(+), 56 deletions(-) create mode 100644 lib/seam/strict_url_search_params_serializer.rb diff --git a/README.md b/README.md index 9ef197b..8f28bc7 100644 --- a/README.md +++ b/README.md @@ -541,9 +541,16 @@ Seam.serialize_url_search_params( custom_metadata_has: {internal_account_id: "user-1"}, limit: 10 ) -# => "custom_metadata_has.internal_account_id=user-1&device_ids=device-1&device_ids=device-2&limit=10" +# => "custom_metadata_has.internal_account_id=user-1&device_ids=device-1&device_ids=device-2&limit=10&_strict=true" ``` +> [!NOTE] +> 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. +> The base serializer without it is available as +> `Seam::UrlSearchParamsSerializer.serialize_url_search_params`. + Use `Seam.update_url_search_params` to merge params into an existing `Seam::UrlSearchParams` collection, e.g. for a URL that already has a query. Parameters that cannot be represented in the standard raise a diff --git a/lib/seam.rb b/lib/seam.rb index f301045..aa2f2f6 100644 --- a/lib/seam.rb +++ b/lib/seam.rb @@ -2,7 +2,7 @@ require_relative "seam/null" require_relative "seam/url_search_params" -require_relative "seam/url_search_params_serializer" +require_relative "seam/strict_url_search_params_serializer" require_relative "seam/http" require_relative "seam/http_without_workspace" require_relative "seam/webhook" diff --git a/lib/seam/request.rb b/lib/seam/request.rb index 2c823c8..cdf7122 100644 --- a/lib/seam/request.rb +++ b/lib/seam/request.rb @@ -5,7 +5,7 @@ require_relative "defaults" require_relative "version" require_relative "paginator" -require_relative "url_search_params_serializer" +require_relative "strict_url_search_params_serializer" module Seam module Http @@ -38,6 +38,11 @@ def self.encode(params) end end + # A query built entirely by the caller passes through verbatim, + # without _strict=true: the caller has chosen their own + # representation, which strict parsing might reject. + return search_params.to_s if map_params.empty? + Seam.update_url_search_params(search_params, map_params) search_params.to_s end diff --git a/lib/seam/strict_url_search_params_serializer.rb b/lib/seam/strict_url_search_params_serializer.rb new file mode 100644 index 0000000..9053ebf --- /dev/null +++ b/lib/seam/strict_url_search_params_serializer.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require_relative "url_search_params_serializer" + +# The strict URL search params serialization used by the Seam SDK: the base +# serializer with strict mode enabled, adding _strict=true to any non-empty +# query so the Seam API uses strict, schema-aware parsing. Kept apart from +# {Seam::UrlSearchParamsSerializer} so the base serializer stays a faithful +# port of the @seamapi/url-search-params-serializer standard. +module Seam + # (see UrlSearchParamsSerializer.serialize_url_search_params) + def self.serialize_url_search_params(params) + UrlSearchParamsSerializer.serialize_url_search_params(params, strict: true) + end + + # (see UrlSearchParamsSerializer.update_url_search_params) + def self.update_url_search_params(search_params, params) + UrlSearchParamsSerializer.update_url_search_params(search_params, params, strict: true) + end +end diff --git a/lib/seam/url_search_params_serializer.rb b/lib/seam/url_search_params_serializer.rb index 2e276f6..96430b7 100644 --- a/lib/seam/url_search_params_serializer.rb +++ b/lib/seam/url_search_params_serializer.rb @@ -19,39 +19,6 @@ def initialize(param_name, reason) end end - # Serializes parameters to a URL query string following the - # @seamapi/url-search-params-serializer standard: - # https://github.com/seamapi/url-search-params-serializer - # - # The output is byte-for-byte identical to the TypeScript reference - # implementation: WHATWG application/x-www-form-urlencoded encoding, - # URLSearchParams#sort ordering, and ECMAScript number formatting. - # - # @param params [Hash] Parameter names mapped to values. Nested hashes join - # their keys with +.+, arrays repeat the name, +nil+ values are omitted, - # and {Seam::NULL} serializes as an empty value. - # @return [String] The query string with no leading +?+. - # @raise [UnserializableParamError] - def self.serialize_url_search_params(params) - search_params = UrlSearchParams.new - update_url_search_params(search_params, params) - search_params.to_s - end - - # Serializes parameters into an existing {UrlSearchParams} collection, - # preserving pairs it does not overwrite, then sorts the collection. Use - # this to merge Seam parameters into a URL that already has a query. - # - # @param search_params [UrlSearchParams] - # @param params [Hash] - # @return [nil] - # @raise [UnserializableParamError] - def self.update_url_search_params(search_params, params) - UrlSearchParamsSerializer.update(search_params, params, []) - search_params.sort! - nil - end - # Returns a copy of the value with every {Seam::NULL} sentinel replaced by # +nil+, recursing into hashes and arrays, so the sentinel serializes to # JSON null in request bodies. Never mutates the given value. @@ -64,9 +31,57 @@ def self.replace_null(value) end end - # Internal implementation of {Seam.serialize_url_search_params}. + # Serializes parameters to a URL query string following the + # @seamapi/url-search-params-serializer standard: + # https://github.com/seamapi/url-search-params-serializer + # + # The output is byte-for-byte identical to the TypeScript reference + # implementation: WHATWG application/x-www-form-urlencoded encoding, + # URLSearchParams#sort ordering, and ECMAScript number formatting. + # + # The SDK itself serializes with {Seam.serialize_url_search_params} and + # {Seam.update_url_search_params}, which enable strict mode; the base + # serializer here keeps strict off by default. module UrlSearchParamsSerializer - def self.update(search_params, params, path) + # @param params [Hash] Parameter names mapped to values. Nested hashes + # join their keys with +.+, arrays repeat the name, +nil+ values are + # omitted, and {Seam::NULL} serializes as an empty value. + # @param strict [Boolean] Whether to add +_strict=true+ to non-empty + # query strings, telling the Seam API to use strict, schema-aware + # parsing. + # @return [String] The query string with no leading +?+. + # @raise [UnserializableParamError] + def self.serialize_url_search_params(params, strict: false) + search_params = UrlSearchParams.new + update_url_search_params(search_params, params, strict: strict) + search_params.to_s + end + + # Serializes parameters into an existing {UrlSearchParams} collection, + # preserving pairs it does not overwrite, then sorts the collection. Use + # this to merge Seam parameters into a URL that already has a query. + # + # @param search_params [UrlSearchParams] + # @param params [Hash] + # @param strict [Boolean] Whether to add +_strict=true+ when the + # resulting collection is non-empty. Any existing +_strict+ pair is + # replaced, and the pair is appended after sorting so it is always + # last. + # @return [nil] + # @raise [UnserializableParamError] + def self.update_url_search_params(search_params, params, strict: false) + nested_update(search_params, params, []) + search_params.sort! + + if strict && !search_params.empty? + search_params.delete("_strict") + search_params.append("_strict", "true") + end + + nil + end + + def self.nested_update(search_params, params, path) params.each do |key, value| unless key.is_a?(String) || key.is_a?(Symbol) raise UnserializableParamError.new( @@ -86,7 +101,7 @@ def self.update(search_params, params, path) current_path = [*path, key] if value.is_a?(Hash) - update(search_params, value, current_path) + nested_update(search_params, value, current_path) next end diff --git a/spec/seam_client/headers_spec.rb b/spec/seam_client/headers_spec.rb index 48bf237..3781fc0 100644 --- a/spec/seam_client/headers_spec.rb +++ b/spec/seam_client/headers_spec.rb @@ -6,7 +6,7 @@ let(:device_id) { "device_id_1234" } it "sends the SDK and auth headers" do - stub = stub_request(:get, "#{Seam::DEFAULT_ENDPOINT}/devices/get?device_id=#{device_id}") + stub = stub_request(:get, "#{Seam::DEFAULT_ENDPOINT}/devices/get?device_id=#{device_id}&_strict=true") .with( headers: { "Authorization" => "Bearer seam_some_api_key", @@ -30,7 +30,7 @@ end it "sends the workspace header with a personal access token" do - stub = stub_request(:get, "#{Seam::DEFAULT_ENDPOINT}/devices/get?device_id=#{device_id}") + stub = stub_request(:get, "#{Seam::DEFAULT_ENDPOINT}/devices/get?device_id=#{device_id}&_strict=true") .with( headers: { "Authorization" => "Bearer seam_at_token", diff --git a/spec/seam_client/url_search_params_spec.rb b/spec/seam_client/url_search_params_spec.rb index 56ae9f2..0c36b01 100644 --- a/spec/seam_client/url_search_params_spec.rb +++ b/spec/seam_client/url_search_params_spec.rb @@ -7,43 +7,43 @@ it "serializes arrays by repeating the name, preserving order" do seam.client.get("/devices/list", {device_ids: %w[device-2 device-1]}) - expect(recorder.requests.first.query).to eq("device_ids=device-2&device_ids=device-1") + expect(recorder.requests.first.query).to eq("device_ids=device-2&device_ids=device-1&_strict=true") end it "serializes an empty array as a single pair with an empty value" do seam.client.get("/devices/list", {device_ids: []}) - expect(recorder.requests.first.query).to eq("device_ids=") + expect(recorder.requests.first.query).to eq("device_ids=&_strict=true") end it "serializes nested objects by joining keys with dots" do seam.client.get("/devices/list", {custom_metadata_has: {internal_account_id: "user-1"}}) - expect(recorder.requests.first.query).to eq("custom_metadata_has.internal_account_id=user-1") + expect(recorder.requests.first.query).to eq("custom_metadata_has.internal_account_id=user-1&_strict=true") end it "encodes with the WHATWG form serializer, escaping ~ and passing * through" do seam.client.get("/devices/list", {search: "a *~ b"}) - expect(recorder.requests.first.query).to eq("search=a+*%7E+b") + expect(recorder.requests.first.query).to eq("search=a+*%7E+b&_strict=true") end it "sorts pairs by name" do seam.client.get("/devices/list", {limit: 5, device_type: "august_lock", search: "x"}) - expect(recorder.requests.first.query).to eq("device_type=august_lock&limit=5&search=x") + expect(recorder.requests.first.query).to eq("device_type=august_lock&limit=5&search=x&_strict=true") end it "omits nil params entirely" do seam.client.get("/devices/list", {search: nil, limit: 1}) - expect(recorder.requests.first.query).to eq("limit=1") + expect(recorder.requests.first.query).to eq("limit=1&_strict=true") end it "serializes the NULL sentinel as an empty value" do seam.client.get("/devices/list", {search: Seam::NULL}) - expect(recorder.requests.first.query).to eq("search=") + expect(recorder.requests.first.query).to eq("search=&_strict=true") end it "emits no bare ? when nothing serializes" do @@ -56,7 +56,7 @@ it "emits the serialized query verbatim through base URL resolution" do seam.client.get("/devices/list", {device_ids: [], search: "a *~ b"}) - expect(recorder.requests.first.target).to eq("/devices/list?device_ids=&search=a+*%7E+b") + expect(recorder.requests.first.target).to eq("/devices/list?device_ids=&search=a+*%7E+b&_strict=true") end it "passes a query string already built by the caller through verbatim" do @@ -68,7 +68,7 @@ it "merges params into a query string already built by the caller" do seam.client.get("/devices/list?built=1", {added: "a b"}) - expect(recorder.requests.first.query).to eq("added=a+b&built=1") + expect(recorder.requests.first.query).to eq("added=a+b&built=1&_strict=true") end it "raises the typed error before any request is sent" do @@ -86,7 +86,7 @@ request = recorder.requests.first expect(request.method).to eq("DELETE") - expect(request.query).to eq("acs_access_group_id=group-1") + expect(request.query).to eq("acs_access_group_id=group-1&_strict=true") end end @@ -137,7 +137,7 @@ request = recorder.requests.first expect(request.method).to eq("GET") - expect(request.target).to eq("/locks/get?device_id=device-1") + expect(request.target).to eq("/locks/get?device_id=device-1&_strict=true") expect(device.device_id).to eq("device-1") end @@ -146,7 +146,7 @@ seam.locks.get(device_id: "device-1", name: nil) - expect(recorder.requests.first.target).to eq("/locks/get?device_id=device-1") + expect(recorder.requests.first.target).to eq("/locks/get?device_id=device-1&_strict=true") end end end diff --git a/spec/url_search_params_serializer_spec.rb b/spec/url_search_params_serializer_spec.rb index 849f666..f751add 100644 --- a/spec/url_search_params_serializer_spec.rb +++ b/spec/url_search_params_serializer_spec.rb @@ -1,8 +1,10 @@ # frozen_string_literal: true RSpec.describe "Seam.serialize_url_search_params" do + # The base serializer isolates the serialization standard; the strict mode + # the SDK uses is layered on top and covered in its own block below. def serialize(params) - Seam.serialize_url_search_params(params) + Seam::UrlSearchParamsSerializer.serialize_url_search_params(params) end it "serializes scalars and sorts pairs by name" do @@ -196,20 +198,49 @@ def serialize(params) end end + describe "strict mode, used by Seam.serialize_url_search_params" do + it "adds _strict=true to non-empty query strings" do + expect(Seam.serialize_url_search_params({})).to eq("") + expect(Seam.serialize_url_search_params({foo: "d"})).to eq("foo=d&_strict=true") + end + + it "keeps _strict last, after the sorted pairs" do + expect(Seam.serialize_url_search_params({z: 1, a: 2})).to eq("a=2&z=1&_strict=true") + end + + it "replaces a _strict param passed by the caller" do + expect(Seam.serialize_url_search_params({_strict: false})).to eq("_strict=true") + end + end + describe "Seam.update_url_search_params" do it "serializes into an existing collection, preserving other pairs, then sorts" do search_params = Seam::UrlSearchParams.new("z=1&a=2") - Seam.update_url_search_params(search_params, {m: "x"}) + Seam::UrlSearchParamsSerializer.update_url_search_params(search_params, {m: "x"}) expect(search_params.to_s).to eq("a=2&m=x&z=1") end it "overwrites pairs with names it serializes" do search_params = Seam::UrlSearchParams.new("a=old") - Seam.update_url_search_params(search_params, {a: "new"}) + Seam::UrlSearchParamsSerializer.update_url_search_params(search_params, {a: "new"}) expect(search_params.to_s).to eq("a=new") end + + it "adds _strict=true when the resulting collection is non-empty" do + search_params = Seam::UrlSearchParams.new("z=1") + Seam.update_url_search_params(search_params, {}) + + expect(search_params.to_s).to eq("z=1&_strict=true") + end + + it "replaces an existing _strict pair rather than repeating it" do + search_params = Seam::UrlSearchParams.new("_strict=true&a=1") + Seam.update_url_search_params(search_params, {b: "2"}) + + expect(search_params.to_s).to eq("a=1&b=2&_strict=true") + end end describe "Seam.replace_null" do From 67f827c302c6a4957963497f9e26fd90163028d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 04:46:43 +0000 Subject: [PATCH 6/9] chore: Trim comments to constraints the code cannot show Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017p9TwdqYv9ccnv7nGvpfsp --- codegen/lib/handlebars-helpers.ts | 5 ++- lib/seam/null.rb | 14 ++------ lib/seam/request.rb | 24 +++----------- .../strict_url_search_params_serializer.rb | 4 +-- lib/seam/url_search_params.rb | 29 ++++------------- lib/seam/url_search_params_serializer.rb | 32 ++++++------------- spec/seam_client/url_search_params_spec.rb | 2 -- spec/support/raw_request_recorder.rb | 3 +- spec/url_search_params_serializer_spec.rb | 2 -- 9 files changed, 29 insertions(+), 86 deletions(-) diff --git a/codegen/lib/handlebars-helpers.ts b/codegen/lib/handlebars-helpers.ts index 9bc5e72..b686580 100644 --- a/codegen/lib/handlebars-helpers.ts +++ b/codegen/lib/handlebars-helpers.ts @@ -94,9 +94,8 @@ export const rubyParameterType = (parameter: Parameter): string => { parameter.format, parameter.format === 'number' && parameter.isInt, ) - // Nullable and optional stay orthogonal: only a nullable parameter accepts - // the Seam::NULL sentinel (serialized as JSON null or an empty query - // value), and only an optional parameter accepts nil (omitted entirely). + // 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') diff --git a/lib/seam/null.rb b/lib/seam/null.rb index 6281239..dd67a61 100644 --- a/lib/seam/null.rb +++ b/lib/seam/null.rb @@ -4,11 +4,6 @@ module Seam # The type of the {Seam::NULL} sentinel. - # - # Exported alongside the sentinel so callers can reference the type itself, - # e.g. in YARD documentation or case expressions. Detect the sentinel with - # +value.is_a?(Seam::Null)+ rather than an identity check, which would break - # if the library were somehow loaded twice. class Null include Singleton @@ -25,11 +20,8 @@ def inspect # # The Seam API distinguishes three states for a parameter: absent (leave the # stored value unchanged), null (unset the stored value), and a value (set - # it). Ruby's +nil+ has to mean one of the first two, and it means absent: - # unsetting a value cannot be undone and is rarely intended, so it is never - # the default and is always spelled explicitly as +Seam::NULL+. - # - # Only use +Seam::NULL+ for parameters the API documents as nullable. In a - # query string it serializes as +name=+ and in a JSON body as +"name": null+. + # it). Ruby's +nil+ means absent; +Seam::NULL+ means null. Only use it for + # parameters the API documents as nullable. In a query string it serializes + # as +name=+ and in a JSON body as +"name": null+. NULL = Null.instance end diff --git a/lib/seam/request.rb b/lib/seam/request.rb index cdf7122..5013c7c 100644 --- a/lib/seam/request.rb +++ b/lib/seam/request.rb @@ -10,20 +10,10 @@ module Seam module Http # The Faraday params encoder that applies the Seam URL search params - # serializer to query params, so requests go out with the exact encoding, - # ordering, and number formatting the Seam API parses. Faraday builds the - # query string by calling this encoder after resolving the request path - # against the base URL, so the serialized query is emitted verbatim. - # - # Faraday's own encoders would silently disagree with the standard: the - # default NestedParamsEncoder turns {ids: []} into "ids%5B%5D" (a bare - # "ids[]" with no "="), which the API reads as no filter at all instead of - # an empty one. + # serializer to query params. module UrlSearchParamsEncoder # Pairs decoded from a query string already present in the request - # path. Wrapping them lets {encode} pass them through verbatim instead - # of re-serializing them: a caller who built their own query string has - # chosen their own representation. + # path, passed through {encode} verbatim rather than re-serialized. Decoded = Struct.new(:values) def self.encode(params) @@ -38,9 +28,6 @@ def self.encode(params) end end - # A query built entirely by the caller passes through verbatim, - # without _strict=true: the caller has chosen their own - # representation, which strict parsing might reject. return search_params.to_s if map_params.empty? Seam.update_url_search_params(search_params, map_params) @@ -152,10 +139,9 @@ def seam_api_error_response?(env) end end - # Replaces every {Seam::NULL} sentinel in a JSON request body with nil, - # so it serializes to JSON null. Runs before the :json request - # middleware and copies the body rather than mutating the caller's - # payload. + # Replaces every {Seam::NULL} sentinel in a request body with nil, so + # it serializes to JSON null. Must run before the :json request + # middleware. class ReplaceNullMiddleware < Faraday::Middleware def on_request(env) return unless env.body.is_a?(Hash) || env.body.is_a?(Array) diff --git a/lib/seam/strict_url_search_params_serializer.rb b/lib/seam/strict_url_search_params_serializer.rb index 9053ebf..ffcf4e2 100644 --- a/lib/seam/strict_url_search_params_serializer.rb +++ b/lib/seam/strict_url_search_params_serializer.rb @@ -4,9 +4,7 @@ # The strict URL search params serialization used by the Seam SDK: the base # serializer with strict mode enabled, adding _strict=true to any non-empty -# query so the Seam API uses strict, schema-aware parsing. Kept apart from -# {Seam::UrlSearchParamsSerializer} so the base serializer stays a faithful -# port of the @seamapi/url-search-params-serializer standard. +# query so the Seam API uses strict, schema-aware parsing. module Seam # (see UrlSearchParamsSerializer.serialize_url_search_params) def self.serialize_url_search_params(params) diff --git a/lib/seam/url_search_params.rb b/lib/seam/url_search_params.rb index 2caaa30..6f38172 100644 --- a/lib/seam/url_search_params.rb +++ b/lib/seam/url_search_params.rb @@ -6,10 +6,6 @@ module Seam # A mutable, ordered list of name/value string pairs modeling the parts of # the WHATWG URLSearchParams interface that the Seam URL search params # serializer needs. A name may repeat, which is how arrays are represented. - # - # Pairs are encoded with the WHATWG application/x-www-form-urlencoded - # serializer and sorted by UTF-16 code unit, matching JavaScript's - # URLSearchParams exactly. class UrlSearchParams include Enumerable @@ -17,10 +13,9 @@ class UrlSearchParams # (with or without a leading +?+), hash, or sequence of name/value pairs. def initialize(init = nil) @pairs = [] + return if init.nil? case init - when nil - # Start empty. when String query = init.delete_prefix("?") URI.decode_www_form(query).each { |name, value| append(name, value) } unless query.empty? @@ -37,8 +32,8 @@ def append(name, value) nil end - # Replaces the value of the first pair with the given name in place and - # deletes the rest, or appends the pair if the name is absent. + # Replaces the value of the first pair with the given name in place, + # deleting the rest, or appends the pair if the name is absent. def set(name, value) name = name.to_s replaced = false @@ -78,14 +73,8 @@ def delete(name) nil end - # Sorts pairs by name in UTF-16 code unit order, like - # URLSearchParams#sort. The sort is stable, so pairs with the same name - # keep their relative order, which preserves array element order. - # - # UTF-16 code unit order differs from both code point order and UTF-8 byte - # order above the Basic Multilingual Plane: surrogate pairs sort below - # U+E000..U+FFFF. Comparing the UTF-16BE encoding of each name as bytes - # produces exactly this order. + # Stably sorts pairs by name in UTF-16 code unit order, like + # URLSearchParams#sort. def sort! @pairs = @pairs.each_with_index.sort_by do |(name, _), index| [name.encode(Encoding::UTF_16BE).b, index] @@ -110,8 +99,7 @@ def empty? end # @return [String] The pairs as an application/x-www-form-urlencoded - # query string with no leading +?+. Every pair gets an +=+, including - # pairs with an empty value. + # query string with no leading +?+. def to_s @pairs.map do |name, value| "#{self.class.encode_component(name)}=#{self.class.encode_component(value)}" @@ -119,10 +107,7 @@ def to_s end # Encodes a string with the WHATWG application/x-www-form-urlencoded - # serializer: ASCII alphanumerics and +*-._+ are emitted literally, space - # becomes ++, and every other UTF-8 byte becomes an uppercase %XX escape. - # Ruby's stdlib implements exactly this, verified by the probe - # +encode_component("a *~ b") == "a+*%7E+b"+ in the specs. + # serializer. def self.encode_component(string) URI.encode_www_form_component(string.encode(Encoding::UTF_8)) end diff --git a/lib/seam/url_search_params_serializer.rb b/lib/seam/url_search_params_serializer.rb index 96430b7..3f4e99b 100644 --- a/lib/seam/url_search_params_serializer.rb +++ b/lib/seam/url_search_params_serializer.rb @@ -6,9 +6,8 @@ require_relative "url_search_params" module Seam - # Raised when a parameter cannot be serialized to a URL query string. This - # is raised before any request is sent, so callers can distinguish a bad - # parameter from an API rejection. + # Raised when a parameter cannot be serialized to a URL query string, + # before any request is sent. class UnserializableParamError < StandardError # @return [String] The name of the parameter that could not be serialized. attr_reader :param_name @@ -35,10 +34,6 @@ def self.replace_null(value) # @seamapi/url-search-params-serializer standard: # https://github.com/seamapi/url-search-params-serializer # - # The output is byte-for-byte identical to the TypeScript reference - # implementation: WHATWG application/x-www-form-urlencoded encoding, - # URLSearchParams#sort ordering, and ECMAScript number formatting. - # # The SDK itself serializes with {Seam.serialize_url_search_params} and # {Seam.update_url_search_params}, which enable strict mode; the base # serializer here keeps strict off by default. @@ -64,9 +59,7 @@ def self.serialize_url_search_params(params, strict: false) # @param search_params [UrlSearchParams] # @param params [Hash] # @param strict [Boolean] Whether to add +_strict=true+ when the - # resulting collection is non-empty. Any existing +_strict+ pair is - # replaced, and the pair is appended after sorting so it is always - # last. + # resulting collection is non-empty. # @return [nil] # @raise [UnserializableParamError] def self.update_url_search_params(search_params, params, strict: false) @@ -123,9 +116,7 @@ def self.nested_update(search_params, params, path) end def self.serialize_array(search_params, name, values) - # An empty array serializes as a single pair with an empty value, which - # the parser reads back as an empty array. Omitting the pair instead - # would silently drop the filter and match everything. + # The parser reads a single pair with an empty value as an empty array. if values.empty? search_params.set(name, "") return @@ -172,9 +163,7 @@ def self.serialize_value(name, value) end end - # Formats a float exactly like ECMAScript Number::toString: the shortest - # digit string that round-trips, a decimal point placed directly for - # exponents in (-7, 21), and e-notation outside that range. + # Formats a float exactly like ECMAScript Number::toString. def self.serialize_float(name, value) raise UnserializableParamError.new(name, "is NaN") if value.nan? if value.infinite? @@ -201,9 +190,8 @@ def self.serialize_float(name, value) end # Returns the shortest round-tripping decimal digits of a positive float - # (trailing zeros stripped) and the position of the decimal point relative - # to the first digit. Ruby's Float#to_s is the shortest repr that - # round-trips, the same digits ECMAScript uses. + # (trailing zeros stripped) and the position of the decimal point + # relative to the first digit, parsed from Float#to_s. def self.shortest_decimal(value) repr = value.to_s @@ -225,9 +213,9 @@ def self.shortest_decimal(value) [digits.sub(/0+\z/, ""), point] end - # Formats a time exactly like JavaScript's Date#toISOString: converted to - # UTC, millisecond precision with sub-millisecond digits truncated, and a - # literal Z. Years outside 0000..9999 use the expanded six-digit form. + # Formats a time exactly like JavaScript's Date#toISOString: UTC, + # millisecond precision with sub-millisecond digits truncated, a literal + # Z, and the expanded six-digit form for years outside 0000..9999. def self.serialize_time(time) utc = time.getutc year = if utc.year.between?(0, 9999) diff --git a/spec/seam_client/url_search_params_spec.rb b/spec/seam_client/url_search_params_spec.rb index 0c36b01..52138b8 100644 --- a/spec/seam_client/url_search_params_spec.rb +++ b/spec/seam_client/url_search_params_spec.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -# These specs assert on the raw query string the SDK puts on the wire, not a -# re-parsed version of it, which would hide encoding differences. RSpec.describe Seam::Http::Request, recorder: true do describe "query params" do it "serializes arrays by repeating the name, preserving order" do diff --git a/spec/support/raw_request_recorder.rb b/spec/support/raw_request_recorder.rb index 481753b..ef3998b 100644 --- a/spec/support/raw_request_recorder.rb +++ b/spec/support/raw_request_recorder.rb @@ -3,8 +3,7 @@ require "socket" # A local HTTP server that records the exact bytes of each request line and -# body. WebMock re-parses URLs, which would hide encoding differences, so the -# URL search params specs assert against the raw request line instead. +# body. class RawRequestRecorder RecordedRequest = Struct.new(:method, :target, :body) do # The raw query string, exactly as it appeared on the request line. diff --git a/spec/url_search_params_serializer_spec.rb b/spec/url_search_params_serializer_spec.rb index f751add..b3b6582 100644 --- a/spec/url_search_params_serializer_spec.rb +++ b/spec/url_search_params_serializer_spec.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true RSpec.describe "Seam.serialize_url_search_params" do - # The base serializer isolates the serialization standard; the strict mode - # the SDK uses is layered on top and covered in its own block below. def serialize(params) Seam::UrlSearchParamsSerializer.serialize_url_search_params(params) end From f9bed0614accf86e7a802edc96caaf40e40a2512 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 04:50:58 +0000 Subject: [PATCH 7/9] chore: Delete comments that restate tested behavior Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017p9TwdqYv9ccnv7nGvpfsp --- lib/seam/null.rb | 10 ++---- lib/seam/request.rb | 3 -- .../strict_url_search_params_serializer.rb | 5 ++- lib/seam/url_search_params.rb | 17 ++-------- lib/seam/url_search_params_serializer.rb | 33 +++++-------------- spec/support/raw_request_recorder.rb | 1 - 6 files changed, 15 insertions(+), 54 deletions(-) diff --git a/lib/seam/null.rb b/lib/seam/null.rb index dd67a61..3900efd 100644 --- a/lib/seam/null.rb +++ b/lib/seam/null.rb @@ -16,12 +16,8 @@ def inspect end end - # Sentinel for an explicit JSON null. - # - # The Seam API distinguishes three states for a parameter: absent (leave the - # stored value unchanged), null (unset the stored value), and a value (set - # it). Ruby's +nil+ means absent; +Seam::NULL+ means null. Only use it for - # parameters the API documents as nullable. In a query string it serializes - # as +name=+ and in a JSON body as +"name": null+. + # 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 diff --git a/lib/seam/request.rb b/lib/seam/request.rb index 5013c7c..6667841 100644 --- a/lib/seam/request.rb +++ b/lib/seam/request.rb @@ -139,9 +139,6 @@ def seam_api_error_response?(env) end end - # Replaces every {Seam::NULL} sentinel in a request body with nil, so - # it serializes to JSON null. Must run before the :json request - # middleware. class ReplaceNullMiddleware < Faraday::Middleware def on_request(env) return unless env.body.is_a?(Hash) || env.body.is_a?(Array) diff --git a/lib/seam/strict_url_search_params_serializer.rb b/lib/seam/strict_url_search_params_serializer.rb index ffcf4e2..2110cf6 100644 --- a/lib/seam/strict_url_search_params_serializer.rb +++ b/lib/seam/strict_url_search_params_serializer.rb @@ -2,9 +2,8 @@ require_relative "url_search_params_serializer" -# The strict URL search params serialization used by the Seam SDK: the base -# serializer with strict mode enabled, adding _strict=true to any non-empty -# query so the Seam API uses strict, schema-aware parsing. +# Strict serialization, used by the SDK itself: _strict=true is added to any +# non-empty query so the Seam API uses strict, schema-aware parsing. module Seam # (see UrlSearchParamsSerializer.serialize_url_search_params) def self.serialize_url_search_params(params) diff --git a/lib/seam/url_search_params.rb b/lib/seam/url_search_params.rb index 6f38172..a010b90 100644 --- a/lib/seam/url_search_params.rb +++ b/lib/seam/url_search_params.rb @@ -5,12 +5,11 @@ module Seam # A mutable, ordered list of name/value string pairs modeling the parts of # the WHATWG URLSearchParams interface that the Seam URL search params - # serializer needs. A name may repeat, which is how arrays are represented. + # serializer needs. class UrlSearchParams include Enumerable - # @param init [String, Hash, Enumerable, nil] An optional query string - # (with or without a leading +?+), hash, or sequence of name/value pairs. + # @param init [String, Hash, Enumerable, nil] def initialize(init = nil) @pairs = [] return if init.nil? @@ -26,14 +25,11 @@ def initialize(init = nil) end end - # Adds a pair, keeping any existing pairs with the same name. def append(name, value) @pairs << [name.to_s, value.to_s] nil end - # Replaces the value of the first pair with the given name in place, - # deleting the rest, or appends the pair if the name is absent. def set(name, value) name = name.to_s replaced = false @@ -48,14 +44,12 @@ def set(name, value) nil end - # @return [String, nil] The value of the first pair with the given name. def get(name) name = name.to_s @pairs.each { |pair_name, value| return value if pair_name == name } nil end - # @return [Array] The values of all pairs with the given name. def get_all(name) name = name.to_s @pairs.filter_map { |pair_name, value| value if pair_name == name } @@ -66,15 +60,12 @@ def has?(name) @pairs.any? { |pair_name, _| pair_name == name } end - # Removes all pairs with the given name. def delete(name) name = name.to_s @pairs.reject! { |pair_name, _| pair_name == name } nil end - # Stably sorts pairs by name in UTF-16 code unit order, like - # URLSearchParams#sort. def sort! @pairs = @pairs.each_with_index.sort_by do |(name, _), index| [name.encode(Encoding::UTF_16BE).b, index] @@ -98,16 +89,12 @@ def empty? @pairs.empty? end - # @return [String] The pairs as an application/x-www-form-urlencoded - # query string with no leading +?+. def to_s @pairs.map do |name, value| "#{self.class.encode_component(name)}=#{self.class.encode_component(value)}" end.join("&") end - # Encodes a string with the WHATWG application/x-www-form-urlencoded - # serializer. def self.encode_component(string) URI.encode_www_form_component(string.encode(Encoding::UTF_8)) end diff --git a/lib/seam/url_search_params_serializer.rb b/lib/seam/url_search_params_serializer.rb index 3f4e99b..30c5d93 100644 --- a/lib/seam/url_search_params_serializer.rb +++ b/lib/seam/url_search_params_serializer.rb @@ -6,10 +6,7 @@ require_relative "url_search_params" module Seam - # Raised when a parameter cannot be serialized to a URL query string, - # before any request is sent. class UnserializableParamError < StandardError - # @return [String] The name of the parameter that could not be serialized. attr_reader :param_name def initialize(param_name, reason) @@ -18,9 +15,6 @@ def initialize(param_name, reason) end end - # Returns a copy of the value with every {Seam::NULL} sentinel replaced by - # +nil+, recursing into hashes and arrays, so the sentinel serializes to - # JSON null in request bodies. Never mutates the given value. def self.replace_null(value) case value when Seam::Null then nil @@ -33,18 +27,11 @@ def self.replace_null(value) # Serializes parameters to a URL query string following the # @seamapi/url-search-params-serializer standard: # https://github.com/seamapi/url-search-params-serializer - # - # The SDK itself serializes with {Seam.serialize_url_search_params} and - # {Seam.update_url_search_params}, which enable strict mode; the base - # serializer here keeps strict off by default. module UrlSearchParamsSerializer - # @param params [Hash] Parameter names mapped to values. Nested hashes - # join their keys with +.+, arrays repeat the name, +nil+ values are - # omitted, and {Seam::NULL} serializes as an empty value. - # @param strict [Boolean] Whether to add +_strict=true+ to non-empty - # query strings, telling the Seam API to use strict, schema-aware - # parsing. - # @return [String] The query string with no leading +?+. + # @param params [Hash] + # @param strict [Boolean] Whether to add +_strict=true+ to a non-empty + # query string + # @return [String] The query string, without a leading +?+ # @raise [UnserializableParamError] def self.serialize_url_search_params(params, strict: false) search_params = UrlSearchParams.new @@ -53,13 +40,12 @@ def self.serialize_url_search_params(params, strict: false) end # Serializes parameters into an existing {UrlSearchParams} collection, - # preserving pairs it does not overwrite, then sorts the collection. Use - # this to merge Seam parameters into a URL that already has a query. + # preserving pairs it does not overwrite. # # @param search_params [UrlSearchParams] # @param params [Hash] # @param strict [Boolean] Whether to add +_strict=true+ when the - # resulting collection is non-empty. + # resulting collection is non-empty # @return [nil] # @raise [UnserializableParamError] def self.update_url_search_params(search_params, params, strict: false) @@ -190,8 +176,7 @@ def self.serialize_float(name, value) end # Returns the shortest round-tripping decimal digits of a positive float - # (trailing zeros stripped) and the position of the decimal point - # relative to the first digit, parsed from Float#to_s. + # and the position of the decimal point relative to the first digit. def self.shortest_decimal(value) repr = value.to_s @@ -213,9 +198,7 @@ def self.shortest_decimal(value) [digits.sub(/0+\z/, ""), point] end - # Formats a time exactly like JavaScript's Date#toISOString: UTC, - # millisecond precision with sub-millisecond digits truncated, a literal - # Z, and the expanded six-digit form for years outside 0000..9999. + # Formats a time exactly like JavaScript's Date#toISOString. def self.serialize_time(time) utc = time.getutc year = if utc.year.between?(0, 9999) diff --git a/spec/support/raw_request_recorder.rb b/spec/support/raw_request_recorder.rb index ef3998b..c5a3cc2 100644 --- a/spec/support/raw_request_recorder.rb +++ b/spec/support/raw_request_recorder.rb @@ -6,7 +6,6 @@ # body. class RawRequestRecorder RecordedRequest = Struct.new(:method, :target, :body) do - # The raw query string, exactly as it appeared on the request line. def query _, query = target.split("?", 2) query From 1db084c880513fa2c287045cb712ea634445074a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 05:44:37 +0000 Subject: [PATCH 8/9] docs: Match the URL search params section to the other SDKs Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017p9TwdqYv9ccnv7nGvpfsp --- README.md | 77 +++++++++++++++++++++++++++++++++---------------------- 1 file changed, 46 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 8f28bc7..fc72c83 100644 --- a/README.md +++ b/README.md @@ -44,7 +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) - - [URL search params serialization](#url-search-params-serialization) + - [Serializing URL search params](#serializing-url-search-params) - [Development and Testing](#development-and-testing) - [Quickstart](#quickstart) - [Source code](#source-code) @@ -520,44 +520,59 @@ 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. -#### URL search params serialization +#### Serializing URL search params -The SDK serializes query params with a Ruby port of -[@seamapi/url-search-params-serializer][serializer], -which the Seam API parses with -[@seamapi/url-search-params-parser][parser]. -The output is byte-for-byte identical to the TypeScript implementation: -WHATWG `application/x-www-form-urlencoded` encoding, -`URLSearchParams.sort()` ordering, and ECMAScript number formatting. +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. -The serializer is exported for callers making requests -with their own HTTP client: +```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" -Seam.serialize_url_search_params( - device_ids: ["device-1", "device-2"], - custom_metadata_has: {internal_account_id: "user-1"}, - limit: 10 -) -# => "custom_metadata_has.internal_account_id=user-1&device_ids=device-1&device_ids=device-2&limit=10&_strict=true" +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" ``` -> [!NOTE] -> 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. -> The base serializer without it is available as -> `Seam::UrlSearchParamsSerializer.serialize_url_search_params`. - -Use `Seam.update_url_search_params` to merge params into an existing -`Seam::UrlSearchParams` collection, e.g. for a URL that already has a query. -Parameters that cannot be represented in the standard raise a -`Seam::UnserializableParamError` before any request is sent, -with the offending parameter name available as `param_name`. - -[serializer]: https://github.com/seamapi/url-search-params-serializer +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 From 66065236961fcc2978abcbd6bef3d569e5c95c8d Mon Sep 17 00:00:00 2001 From: Evan Sosenko Date: Thu, 13 Aug 2026 22:51:50 -0700 Subject: [PATCH 9/9] Move UrlSearchParamsEncoder down --- lib/seam/request.rb | 70 ++++++++++++++++++++++----------------------- 1 file changed, 34 insertions(+), 36 deletions(-) diff --git a/lib/seam/request.rb b/lib/seam/request.rb index 6667841..ba87950 100644 --- a/lib/seam/request.rb +++ b/lib/seam/request.rb @@ -9,42 +9,6 @@ module Seam module Http - # The Faraday params encoder that applies the Seam URL search params - # serializer to query params. - 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 - module Request def self.create_faraday_client(endpoint, auth_headers, faraday_options = {}, faraday_retry_options = {}, timeout: nil) @@ -161,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